@homie-rocks/studio 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -1,5 +1,8 @@
1
1
  /**
2
- * `homie-studio assets check <id>`: static, free, on every asset and on the whole game.
2
+ * `homie-studio assets check <id>`: static, free, on every asset and on the whole game. What it adds up is an
3
+ * INVENTORY ESTIMATE: the recorded files' own triangles, draw calls and pictures times their declared placements. It
4
+ * never ran the game, so it cannot see what the code builds or repeats (UNMEASURED_AT_RUNTIME); a pass here is not a
5
+ * measured frame. The playtest and perf guides measure the running game.
3
6
  *
4
7
  * per asset the file is there and is the one recorded (SHA-256); the safety check (assets/safety.mjs); the Khronos
5
8
  * glTF-Validator; triangles, materials, picture sizes, bones and bytes against its tier's budget
@@ -8,8 +11,13 @@
8
11
  * characters four influences a vertex (three.js reads no more), bones within the tier, its skeleton's clip library
9
12
  * present, and every verb the game needs (anim.clips) in it
10
13
  * clips a skeleton's clip library (kind clip): at most 1 MB (3 MB hard), sampled at no more than 30 a second
11
- * the game shipped models with no record; draw calls, triangles and picture memory summed (each asset times its
12
- * `placements`, instanced ones drawn once); the first-play download from the last build (gzip); a
14
+ * pictures a standalone texture or sky record: each shipped picture there, the one recorded (SHA-256), decodable,
15
+ * its size against PICTURES, and its mipmapped memory added ONCE per unique picture
16
+ * the game shipped models with no record; shipped pictures with no record (named apart from pictures the game
17
+ * folder keeps but never ships); draw calls, triangles and picture memory summed (each asset times its
18
+ * `placements`, instanced ones drawn once; an asset whose `usage` is "unused" is checked but left out of
19
+ * the scene sums); the shipped payload of the last build (every built file, gzip: a conservative gate,
20
+ * never a trace of what a browser fetched before the first round); a
13
21
  * committed binary over 5 MB under games/ (it belongs in R2, never in git); skinning on a phone: the
14
22
  * room's players times the heaviest character's skinned vertices and bones, against SKINNING (beyond it,
15
23
  * far characters must pose less often: @homie-rocks/studio/animate's crowd())
@@ -17,11 +25,12 @@
17
25
  * The result also goes to .studio/art/<id>/check.json, which the Studio mod's Art tab and the cards read.
18
26
  */
19
27
  import { spawnSync } from 'node:child_process';
28
+ import { createHash } from 'node:crypto';
20
29
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
21
30
  import { join } from 'node:path';
22
31
  import { gzipSync } from 'node:zlib';
23
32
  import { LIMITS, checkGlb } from '../assets/safety.mjs';
24
- import { licenceProblems, readManifest, unrecordedModels } from './asset-manifest.mjs';
33
+ import { licenceProblems, readManifest, unrecordedModels, unrecordedPictures, usageOf } from './asset-manifest.mjs';
25
34
  import { readDecisions, staleAssets } from './decisions.mjs';
26
35
 
27
36
  /** The phone tier: the defaults when the game has no cast.tiers decision. Hard caps are never loosened. */
@@ -33,6 +42,23 @@ export const BUDGETS = Object.freeze({
33
42
  kit: { triangles: 1000, texturePx: 512, kb: 200, bones: 0, materials: 1 },
34
43
  scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 },
35
44
  });
45
+ /**
46
+ * A standalone picture (a texture or sky record's own file, not one inside a .glb): the longest side it is expected
47
+ * to stay under (a note past it) and the side no phone should be handed (a problem past it). A sky is one picture
48
+ * around the whole world, so it is allowed twice a surface's.
49
+ */
50
+ export const PICTURES = Object.freeze({ texture: { px: 1024, hardPx: 2048 }, sky: { px: 2048, hardPx: 4096 } });
51
+ /**
52
+ * What this check cannot see, because it reads files and never runs the game. Printed with every result, so a pass is
53
+ * never read as a measured frame.
54
+ */
55
+ export const UNMEASURED_AT_RUNTIME = Object.freeze([
56
+ 'geometry the game\'s code builds (terrain, procedural props, generated meshes with no record)',
57
+ 'copies the code draws beyond each record\'s `placements` (repeated characters, weapons, scattered props)',
58
+ 'effects and particles',
59
+ 'shadow passes and post-processing passes (each draws the scene again)',
60
+ 'the HUD and any picture the code loads that has no record',
61
+ ]);
36
62
  /**
37
63
  * Skinning on a phone, every frame (judgment, from the house's 32-player rooms; the perf skill's measured gate decides):
38
64
  * the vertices the GPU skins and the bones the CPU poses. Characters past `crowdFrom` need crowd mode.
@@ -41,20 +67,91 @@ export const SKINNING = Object.freeze({ vertices: 60_000, bones: 1_200, crowdFro
41
67
  export const HARD_CAPS = Object.freeze({ triangles: { hero: 15000, npc: 6000, prop: 8000, signature: 8000, kit: 3000 }, texturePx: 2048, kb: 5120, bones: 64, materials: 2, scene: { drawCalls: 150, triangles: 250_000, textureMB: 64, firstPlayMB: 8 } });
42
68
  const BIG_FILE = 5 * 1024 * 1024;
43
69
 
70
+ const TIERS = ['hero', 'npc', 'prop', 'signature', 'kit'];
71
+ /** The tiers a game may set a budget for (game.json assets.budgets.<tier>). */
72
+ export const BUDGET_TIERS = Object.freeze([...TIERS]);
44
73
  const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
45
74
 
46
- /** The budgets this game holds itself to: its cast.tiers decision over the phone defaults, never past a hard cap. */
47
- export function budgetsFor(doc) {
75
+ /** A game's game.json, or {} (a planned game has none yet). */
76
+ export function gameJson(root, id) {
77
+ try { return JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')) ?? {}; } catch { return {}; }
78
+ }
79
+
80
+ /*
81
+ * ONE KEY, THREE READERS. game.json `assets.budgets` is read by `assets check` and `assets add` (budgetsFor, below)
82
+ * and by the build, which hands it to the game's own loader (`createModels()`, as __HOMIE_MODEL_BUDGETS__). They are
83
+ * one reading: `gameBudgets` is the only code that parses the key, `budgetsFor` is what a check and an import hold a
84
+ * model of a tier to, and `loaderBudget` is what the build gives the loader, which knows no tiers: the loosest thing
85
+ * the game declared, under the same hard caps. So the loader never warns about a model the check passes under the
86
+ * game's own budgets, and a number past a hard cap is past it for all three.
87
+ */
88
+ /** The fields a game may set per model (and per tier). `materials` is the loader's alone: the check's is a hard cap. */
89
+ export const BUDGET_FIELDS = Object.freeze(['triangles', 'bytes', 'texturePx']);
90
+ /** The most any tier may be allowed, whatever a game declares: what the loader's one budget is held under. */
91
+ export const LOADER_CAPS = Object.freeze({ get triangles() { return Math.max(...Object.values(HARD_CAPS.triangles)); }, get bytes() { return HARD_CAPS.kb * 1024; }, get texturePx() { return HARD_CAPS.texturePx; } });
92
+
93
+ /**
94
+ * What the build hands the game's loader for one model, from the same declaration: per field, the largest the game
95
+ * declared flat or for any tier, never past the hard cap. Null when the game declares none (the loader's own
96
+ * defaults, a small prop's, then stand). `capped`: the fields that were over a cap, with what was asked.
97
+ */
98
+ export function loaderBudget(game) {
99
+ const d = gameBudgets(game);
100
+ if (!d) return null;
101
+ const out = {}; const capped = [];
102
+ for (const k of BUDGET_FIELDS) {
103
+ const asked = Math.max(0, d[k] ?? 0, ...Object.values(d.tiers ?? {}).map((t) => t[k] ?? 0));
104
+ if (!asked) continue;
105
+ out[k] = Math.min(asked, LOADER_CAPS[k]);
106
+ if (asked > LOADER_CAPS[k]) capped.push({ field: k, asked, cap: LOADER_CAPS[k] });
107
+ }
108
+ return Object.keys(out).length ? { budget: out, capped } : null;
109
+ }
110
+
111
+ /**
112
+ * The per-model budget a game declares for itself: game.json `assets.budgets` ({ triangles, bytes }, the same key the
113
+ * runtime loader's development warnings read; `texturePx` too when given). Flat for every model, or one object a
114
+ * tier ({ prop: { triangles }, hero: { ... } }). Absent, or not an object: null, and nothing changes.
115
+ */
116
+ export function gameBudgets(game) {
117
+ // `"assets": "library"` (a 3D starter's note of where its models come from) is a string, not budgets: none declared.
118
+ const b = game?.assets && typeof game.assets === 'object' ? game.assets.budgets : undefined;
119
+ if (!b || typeof b !== 'object' || Array.isArray(b)) return null;
120
+ const read = (o) => {
121
+ const out = {};
122
+ // A whole number above zero, as the build reads it for the loader (lib/build.mjs modelBudgetsOf): one rule.
123
+ for (const k of BUDGET_FIELDS) { const n = Math.floor(Number(o?.[k])); if (Number.isFinite(n) && n >= 1) out[k] = n; }
124
+ return out;
125
+ };
126
+ const flat = read(b);
127
+ const tiers = Object.fromEntries(TIERS.filter((t) => b[t] && typeof b[t] === 'object').map((t) => [t, read(b[t])]).filter(([, v]) => Object.keys(v).length));
128
+ return Object.keys(flat).length || Object.keys(tiers).length ? { ...flat, ...(Object.keys(tiers).length ? { tiers } : {}) } : null;
129
+ }
130
+
131
+ /**
132
+ * The budgets this game holds itself to: its cast.tiers decision over the phone defaults, then what its game.json
133
+ * `assets.budgets` declares for a model (it RAISES a tier's triangles, bytes and picture size to that, so the check and
134
+ * the loader's warnings agree; it never lowers a hero to a prop's size), never past a hard cap.
135
+ */
136
+ export function budgetsFor(doc, game = null) {
48
137
  const v = doc?.decisions?.['cast.tiers']?.value ?? {};
49
138
  const out = {};
50
139
  for (const [k, def] of Object.entries(BUDGETS)) out[k] = { ...def, ...(typeof v[k] === 'object' ? v[k] : {}) };
51
- for (const t of ['hero', 'npc', 'prop', 'signature', 'kit']) {
140
+ // The scene's payload budget under its honest name too (a decision written before it was renamed still counts).
141
+ if (Number.isFinite(Number(v.scene?.shippedPayloadMB))) out.scene.firstPlayMB = Number(v.scene.shippedPayloadMB);
142
+ const declared = gameBudgets(game);
143
+ for (const t of TIERS) {
144
+ const own = declared ? { ...declared, ...(declared.tiers?.[t] ?? {}) } : null;
145
+ if (own?.triangles) out[t].triangles = Math.max(out[t].triangles, own.triangles);
146
+ if (own?.bytes) out[t].kb = Math.max(out[t].kb, Math.ceil(own.bytes / 1024));
147
+ if (own?.texturePx) out[t].texturePx = Math.max(out[t].texturePx, own.texturePx);
52
148
  out[t].triangles = Math.min(out[t].triangles, HARD_CAPS.triangles[t]);
53
149
  out[t].texturePx = Math.min(out[t].texturePx, HARD_CAPS.texturePx);
54
150
  out[t].kb = Math.min(out[t].kb, HARD_CAPS.kb);
55
151
  out[t].bones = Math.min(out[t].bones ?? 0, HARD_CAPS.bones);
56
152
  }
57
153
  for (const k of ['drawCalls', 'triangles', 'textureMB', 'firstPlayMB']) out.scene[k] = Math.min(out.scene[k], HARD_CAPS.scene[k]);
154
+ out.scene.shippedPayloadMB = out.scene.firstPlayMB;
58
155
  return out;
59
156
  }
60
157
 
@@ -65,8 +162,13 @@ export function bigCommittedFiles(root) {
65
162
  return r.stdout.split('\0').filter(Boolean).map((p) => { try { return { path: p, bytes: statSync(join(root, p)).size }; } catch { return null; } }).filter((x) => x && x.bytes > BIG_FILE);
66
163
  }
67
164
 
68
- /** The first-play download of the last build of this game: every file under site/dist/games/<id>, gzipped. */
69
- export function firstPlayBytes(root, id) {
165
+ /**
166
+ * The SHIPPED PAYLOAD of the last build of this game: every file under site/dist/games/<id>, gzipped (one format a
167
+ * sound). It is what the game ships, and so the most a first visit could fetch: a conservative gate. It is not what a
168
+ * browser downloads before the first round (a model the code never loads and music that streams are both in it, and
169
+ * nothing here watched a network): the playtest measures that.
170
+ */
171
+ export function shippedPayloadBytes(root, id) {
70
172
  const dir = join(root, 'site', 'dist', 'games', id);
71
173
  if (!existsSync(dir)) return null;
72
174
  let raw = 0; let gz = 0; let files = 0;
@@ -93,32 +195,77 @@ export function firstPlayBytes(root, id) {
93
195
  for (const bytes of audio.values()) { raw += bytes; gz += bytes; files += 1; }
94
196
  return { raw, gzip: gz, files };
95
197
  }
198
+ /** The old name of shippedPayloadBytes (0.22.0 to 0.31): kept so a script that imports it still runs. */
199
+ export const firstPlayBytes = shippedPayloadBytes;
200
+
201
+ const PICTURE_FILE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
202
+ const mipmapped = (w, h, bytesPerPixel = 4) => Math.round(w * h * bytesPerPixel * (4 / 3));
203
+
204
+ /**
205
+ * One shipped picture, measured: its SHA-256, its size in pixels, whether it decodes, and the memory it takes once a
206
+ * renderer has it with mipmaps (RGBA8: width x height x 4 x 4/3; a Radiance .hdr as half floats, twice that). An
207
+ * estimate of what a GPU is asked for, never a reading of one. `why` says what stopped a measurement.
208
+ */
209
+ export async function measurePicture(abs) {
210
+ const bytes = readFileSync(abs);
211
+ const base = { bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex'), px: null, format: null, gpuBytes: 0, why: null };
212
+ if (/\.hdr$/i.test(abs)) {
213
+ // A Radiance picture's size is in its text header ("-Y 1024 +X 2048"); its pixels are not decoded here.
214
+ const m = /^[-+]Y\s+(\d+)\s+[-+]X\s+(\d+)\s*$/m.exec(bytes.subarray(0, 4096).toString('latin1'));
215
+ if (!m) return { ...base, why: 'not a readable Radiance .hdr (no size in its header)' };
216
+ return { ...base, px: [Number(m[2]), Number(m[1])], format: 'hdr', gpuBytes: mipmapped(Number(m[2]), Number(m[1]), 8), decoded: false };
217
+ }
218
+ if (/\.(exr|ktx2)$/i.test(abs)) return { ...base, why: `a ${abs.split('.').pop().toLowerCase()} picture: this check cannot decode it, so its size and memory are not measured` };
219
+ let sharp = null;
220
+ try { sharp = (await import('sharp')).default; } catch { sharp = null; }
221
+ if (!sharp) return { ...base, why: 'sharp is not installed here, so pictures are not measured (npm install in the studio)' };
222
+ try {
223
+ const meta = await sharp(bytes).metadata();
224
+ // Decoded all the way (a truncated file has a fine header): to a few pixels, which reads every row.
225
+ await sharp(bytes).resize(8, 8, { fit: 'fill' }).raw().toBuffer();
226
+ return { ...base, px: [meta.width, meta.height], format: meta.format ?? null, gpuBytes: mipmapped(meta.width, meta.height), decoded: true };
227
+ } catch (error) { return { ...base, why: `it does not decode (${String(error.message).split('\n')[0].slice(0, 120)})` }; }
228
+ }
96
229
 
97
230
  const kb = (n) => Math.round(n / 1024);
98
231
 
99
- /** `assets check`: { ok, game, rows, totals, budgets, licence, stale, unrecorded, bigFiles, firstPlay, problems }. */
232
+ /**
233
+ * `assets check`: { ok, scope: 'inventory-estimate', game, rows, totals, estimate, budgets, licence, stale, unrecorded,
234
+ * unrecordedPictures, bigFiles, shippedPayload (and `firstPlay`, its old name), problems }.
235
+ */
100
236
  export async function assetsCheck(root, id, { validate = true, write = true } = {}) {
101
237
  const manifest = readManifest(root, id);
102
238
  const doc = readDecisions(root, id);
103
- const budgets = budgetsFor(doc);
239
+ const game = gameJson(root, id);
240
+ const budgets = budgetsFor(doc, game);
104
241
  const gdir = join(root, 'games', id);
105
242
  let tools = null;
106
243
  try { tools = await import('./optimise.mjs'); } catch { tools = null; }
107
244
  const rows = [];
108
- let tris = 0; let draws = 0; let texBytes = 0;
245
+ let tris = 0; let draws = 0; let texBytes = 0; let unusedMapBytes = 0; let undeclaredMapBytes = 0; let pictureBytes = 0;
109
246
  const seenFiles = new Set();
110
- const { verbsFor } = await import('./characters.mjs');
247
+ // A picture two records name (or one file copied to two paths) is one picture in memory: counted by its bytes.
248
+ const seenPictures = new Set();
249
+ const { verbsFor, librariesFor, duplicateLibraries } = await import('./characters.mjs');
111
250
  const needVerbs = verbsFor(doc);
112
- const libs = new Map(manifest.assets.filter((x) => x.kind === 'clip').map((x) => [x.rig?.skeleton, x]));
251
+ const strays = duplicateLibraries(manifest);
113
252
  let heaviest = { vertices: 0, bones: 0, id: null };
253
+ // What the game draws a model's materials with: every map the file has (`all`), or its base colour only (`base`,
254
+ // what stylize() keeps for a toon, flat, pixel or hand-painted look). Only a DECLARATION changes the sum: on one
255
+ // record (`inGame.maps`) or for the whole manifest (its top-level `inGame.maps`). Nothing is read from the code.
256
+ const mapsOf = (a) => (['base', 'all'].includes(a.inGame?.maps) ? a.inGame.maps : ['base', 'all'].includes(manifest.inGame?.maps) ? manifest.inGame.maps : null);
114
257
  for (const a of manifest.assets) {
115
258
  const problems = []; const warnings = [];
116
259
  const tier = tierOf(a);
117
260
  const b = budgets[tier] ?? budgets.prop;
118
261
  const n = Math.max(1, Number(a.placements ?? 1) || 1);
119
- if (a.kind === 'clip') { rows.push(await clipRow(gdir, a, tools)); continue; }
262
+ if (a.kind === 'clip') { const row = await clipRow(gdir, a, tools); if (strays.has(a.id)) row.warnings.push(strays.get(a.id)); rows.push(row); continue; }
263
+ // An asset the game no longer draws (usage "unused": a legacy model kept in the folder) is still checked, and still
264
+ // ships, but is no part of a scene: it stays out of the sums.
265
+ const usage = usageOf(a);
266
+ const counted = usage !== 'unused';
120
267
  const model = (a.files ?? []).find((f) => f.role === 'model');
121
- let m = null;
268
+ let m = null; let unmeasured = null; let unusedKB = 0;
122
269
  if (model) {
123
270
  const abs = join(gdir, model.path);
124
271
  if (!existsSync(abs)) problems.push(`${model.path} is missing`);
@@ -142,58 +289,104 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
142
289
  if (bytes.byteLength / 1024 > b.kb) problems.push(`${kb(bytes.byteLength)} KB (a ${tier}'s budget is ${b.kb} KB)`);
143
290
  if (m.bones > (b.bones || HARD_CAPS.bones)) problems.push(`${m.bones} bones (a ${tier}'s budget is ${b.bones || HARD_CAPS.bones})`);
144
291
  if (m.influences > 4) problems.push(`${m.influences} joints move one vertex (JOINTS_1): three.js reads 4, so the rest are dropped and the mesh tears; export with 4 influences a vertex`);
145
- if (m.skinnedVertices > heaviest.vertices) heaviest = { ...heaviest, vertices: m.skinnedVertices, id: a.id };
146
- if (m.bones > heaviest.bones) heaviest.bones = m.bones;
292
+ if (counted && m.skinnedVertices > heaviest.vertices) heaviest = { ...heaviest, vertices: m.skinnedVertices, id: a.id };
293
+ if (counted && m.bones > heaviest.bones) heaviest.bones = m.bones;
147
294
  if (a.rig) {
148
- const lib = libs.get(a.rig.skeleton);
295
+ // The library is the one this model names (rig.anims), with any library declared supplemental for its
296
+ // skeleton: never "whichever clip record of that skeleton was read last".
297
+ const lib = librariesFor(manifest, a);
149
298
  const animsFile = a.rig.anims ? join(gdir, a.rig.anims) : null;
150
299
  if (!animsFile || !existsSync(animsFile)) problems.push(`its clip library ${a.rig.anims ?? '(none named)'} is missing: homie-studio anim add ${id} ${a.id} --verbs ${needVerbs.join(',')}`);
151
300
  else {
152
- const have = lib?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
153
- const lacking = needVerbs.filter((v) => !have.includes(v));
301
+ const lacking = needVerbs.filter((v) => !lib.verbs.includes(v));
154
302
  if (lacking.length) warnings.push(`the game's clips (anim.clips) name ${lacking.join(', ')}, which ${a.id}'s clip library has not: homie-studio anim add ${id} ${a.id} --verbs ${lacking.join(',')}`);
155
303
  }
156
304
  }
157
305
  if (m.pivot && !(m.pivot.bottom && m.pivot.centred)) warnings.push('its pivot is not at the bottom centre (assets optimise puts it there)');
158
306
  const want = Number(a.heightM ?? a.card?.heightM ?? 0);
159
307
  if (want > 0 && m.heightM && Math.abs(m.heightM / want - 1) > 0.1) warnings.push(`${m.heightM} m tall; its card says ${want} m`);
160
- if (!seenFiles.has(model.path)) { texBytes += m.textureGpuBytes; seenFiles.add(model.path); }
161
- tris += m.triangles * n;
162
- draws += a.instanced ? m.drawCalls : m.drawCalls * n;
308
+ // Pictures no base-colour slot uses (normal, occlusion/roughness/metal, emissive): counted unless the game
309
+ // DECLARES it draws base colour only. Said either way, so the number is never silently one or the other.
310
+ const extra = m.textures.filter((t) => !(t.slots ?? []).includes('baseColorTexture')).reduce((s, t) => s + t.gpuBytes, 0);
311
+ const maps = mapsOf(a);
312
+ const dropped = maps === 'base' ? extra : 0;
313
+ unusedKB = kb(dropped);
314
+ if (extra && maps === 'base') warnings.push(`${(extra / 1024 / 1024).toFixed(1)} MB of its normal and roughness maps are left out of picture memory: declared base colour only (inGame.maps: "base"). The file still ships them (the download and the decode stay): assets optimise can drop them`);
315
+ if (counted && !seenFiles.has(model.path)) {
316
+ texBytes += m.textureGpuBytes - dropped; unusedMapBytes += dropped;
317
+ if (extra && !maps) undeclaredMapBytes += extra;
318
+ seenFiles.add(model.path);
319
+ }
320
+ if (counted) { tris += m.triangles * n; draws += a.instanced ? m.drawCalls : m.drawCalls * n; }
163
321
  }
164
322
  }
165
323
  } else if (a.route !== 'procedural' && !['texture', 'sky'].includes(a.kind)) problems.push('no model file recorded');
324
+ // Standalone pictures (a texture or sky record's own files, and any shipped picture a model's record names beside
325
+ // it): there, the ones recorded, decodable, within PICTURES, and in picture memory once each.
326
+ const pictures = [];
166
327
  for (const f of (a.files ?? []).filter((x) => x.role !== 'model' && x.public !== false && /^public\//.test(x.path ?? ''))) {
167
- if (!existsSync(join(gdir, f.path))) problems.push(`${f.path} is missing`);
328
+ const abs = join(gdir, f.path);
329
+ if (!existsSync(abs)) { problems.push(`${f.path} is missing`); continue; }
330
+ if (!PICTURE_FILE.test(f.path)) continue;
331
+ const p = await measurePicture(abs);
332
+ if (f.sha256 && p.sha256 !== f.sha256) warnings.push(`${f.path} changed since it was recorded (its SHA-256 differs): record it again so its provenance is true`);
333
+ if (!f.sha256) warnings.push(`${f.path} has no SHA-256 in its record: nothing proves it is the file that was recorded`);
334
+ if (!p.px) { problems.push(`${f.path}: ${p.why}`); continue; }
335
+ const lim = PICTURES[a.kind === 'sky' || f.role === 'sky' ? 'sky' : 'texture'];
336
+ const side = Math.max(p.px[0], p.px[1]);
337
+ if (side > lim.hardPx) problems.push(`${f.path} is ${p.px[0]} x ${p.px[1]} px (at most ${lim.hardPx} px a side for a ${lim === PICTURES.sky ? 'sky' : 'texture'})`);
338
+ else if (side > lim.px) warnings.push(`${f.path} is ${p.px[0]} x ${p.px[1]} px: about ${(p.gpuBytes / 1024 / 1024).toFixed(1)} MB of picture memory with mipmaps (${lim.px} px is plenty on a phone)`);
339
+ if (counted && !seenPictures.has(p.sha256)) { seenPictures.add(p.sha256); texBytes += p.gpuBytes; pictureBytes += p.gpuBytes; }
340
+ pictures.push({ path: f.path, px: p.px, format: p.format, gpuKB: kb(p.gpuBytes), kb: kb(p.bytes) });
168
341
  }
169
- if (a.route === 'procedural' && a.measured) { tris += (a.measured.tris ?? 0) * n; draws += a.instanced ? (a.measured.drawCalls ?? 1) : (a.measured.drawCalls ?? 1) * n; }
170
- rows.push({ id: a.id, kind: a.kind, tier, route: a.route, ok: problems.length === 0, problems, warnings, measured: m ? { tris: m.triangles, drawCalls: m.drawCalls, materials: m.materials, maxTexturePx: m.maxTexturePx, textureKB: kb(m.textureGpuBytes), bones: m.bones, heightM: m.heightM, kb: model ? kb(statSync(join(gdir, model.path)).size) : null } : null, budget: b, placements: n });
342
+ if (a.route === 'procedural' && a.measured && counted) { tris += (a.measured.tris ?? 0) * n; draws += a.instanced ? (a.measured.drawCalls ?? 1) : (a.measured.drawCalls ?? 1) * n; }
343
+ let measured = m ? { tris: m.triangles, drawCalls: m.drawCalls, materials: m.materials, maxTexturePx: m.maxTexturePx, textureKB: kb(m.textureGpuBytes) - unusedKB + pictures.reduce((s, p) => s + p.gpuKB, 0), bones: m.bones, heightM: m.heightM, kb: model ? kb(statSync(join(gdir, model.path)).size) : null, ...(unusedKB ? { unusedTextureKB: unusedKB } : {}), ...(pictures.length ? { pictures } : {}) } : null;
344
+ if (!m && pictures.length) measured = { tris: 0, drawCalls: 0, materials: 0, maxTexturePx: pictures.reduce((s, p) => Math.max(s, p.px[0], p.px[1]), 0), textureKB: pictures.reduce((s, p) => s + p.gpuKB, 0), bones: 0, heightM: null, kb: pictures.reduce((s, p) => s + p.kb, 0), pictures };
345
+ // Nothing was measured: said, never shown as a plain pass. A procedural part's numbers are its record's own claim.
346
+ if (!measured && !problems.length) unmeasured = a.route === 'procedural' ? (a.measured ? 'drawn by the game\'s code: the triangles and draw calls are what its record says, not a reading' : 'drawn by the game\'s code, with no numbers in its record: no part of the estimate') : 'no shipped file of it could be measured';
347
+ rows.push({ id: a.id, kind: a.kind, tier, route: a.route, usage, counted, ok: problems.length === 0, problems, warnings, measured, ...(unmeasured ? { unmeasured } : {}), budget: b, placements: n });
171
348
  }
172
349
  // Skinning on a phone: the room's players, each the heaviest character (the worst case a room can draw).
173
- let players = 8;
174
- try { players = Math.max(1, Number(JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).players?.max ?? 8) || 8); } catch { players = 8; }
350
+ const players = Math.max(1, Number(game.players?.max ?? 8) || 8);
175
351
  const skinning = heaviest.vertices ? { players, character: heaviest.id, vertices: heaviest.vertices * players, bones: heaviest.bones * players, budget: { vertices: SKINNING.vertices, bones: SKINNING.bones } } : null;
176
- const licence = licenceProblems(root, id, manifest, { public: publicSource(root, id) });
352
+ const licence = licenceProblems(root, id, manifest);
177
353
  const stale = doc ? staleAssets(doc, manifest) : [];
178
354
  const unrecorded = unrecordedModels(root, id, manifest);
355
+ // Shipped pictures no record names: measured, so the gap has a size, but kept OUT of the sum (nothing says the game
356
+ // loads them as textures: a cover and a HUD icon are pictures too). Apart from pictures the folder never ships.
357
+ const loose = unrecordedPictures(root, id, manifest, { cover: game.cover ?? null });
358
+ const shipped = [];
359
+ for (const path of loose.shipped.slice(0, 60)) { const p = await measurePicture(join(gdir, path)).catch(() => null); shipped.push({ path, px: p?.px ?? null, gpuKB: p?.px ? kb(p.gpuBytes) : null, ...(p?.px ? {} : { why: p?.why ?? 'not read' }) }); }
360
+ const unrecordedPics = { shipped, more: Math.max(0, loose.shipped.length - shipped.length), mipmappedMB: +(shipped.reduce((s, p) => s + (p.gpuKB ?? 0), 0) / 1024).toFixed(1), notShipped: loose.notShipped };
179
361
  const bigFiles = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${id}/`));
180
- const firstPlay = firstPlayBytes(root, id);
362
+ const shippedPayload = shippedPayloadBytes(root, id);
181
363
  const s = budgets.scene;
182
- const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null, skinnedVertices: skinning?.vertices ?? 0, bones: skinning?.bones ?? 0 };
364
+ const payloadMB = shippedPayload ? +(shippedPayload.gzip / 1024 / 1024).toFixed(2) : null;
365
+ // Only what a record marks unused: a clip library is drawn by nothing and is no part of the sums either way.
366
+ const unused = rows.filter((r) => r.usage === 'unused').map((r) => r.id);
367
+ // `firstPlayMB` is the old key of `shippedPayloadMB` (the same number): the cards and scripts written against 0.30
368
+ // still read it. It never was a measured first-play download.
369
+ const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), standaloneTextureMB: +(pictureBytes / 1024 / 1024).toFixed(1), unusedMapMB: +(unusedMapBytes / 1024 / 1024).toFixed(1), shippedPayloadMB: payloadMB, firstPlayMB: payloadMB, skinnedVertices: skinning?.vertices ?? 0, bones: skinning?.bones ?? 0, unused: unused.length };
183
370
  const problems = [];
184
371
  if (skinning && (skinning.vertices > SKINNING.vertices || skinning.bones > SKINNING.bones)) {
185
372
  const crowd = `far characters must pose less often: call crowd(characters, camera) from @homie-rocks/studio/animate every frame (and lighter characters, or fewer, for the rest)`;
186
373
  if (players >= SKINNING.crowdFrom) problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame on a phone (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): ${crowd}`);
187
374
  else problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): a lighter character (assets optimise --triangles), or ${crowd}`);
188
375
  }
189
- if (totals.drawCalls > s.drawCalls) problems.push(`about ${totals.drawCalls} draw calls in a scene (the phone budget is ${s.drawCalls}): instance repeated props (placements with instanced: true), or fewer materials`);
190
- if (totals.triangles > s.triangles) problems.push(`about ${totals.triangles.toLocaleString('en-US')} triangles in a scene (the phone budget is ${s.triangles.toLocaleString('en-US')})`);
191
- if (totals.textureMB > s.textureMB) problems.push(`${totals.textureMB} MB of picture memory (the phone budget is ${s.textureMB} MB; an iPhone page dies near 100 MB)`);
192
- if (totals.firstPlayMB !== null && totals.firstPlayMB > s.firstPlayMB) problems.push(`${totals.firstPlayMB} MB to download before the first round (the budget is ${s.firstPlayMB} MB)`);
376
+ if (totals.drawCalls > s.drawCalls) problems.push(`the recorded assets alone add up to about ${totals.drawCalls} draw calls (the phone budget is ${s.drawCalls}): instance repeated props (placements with instanced: true), or fewer materials`);
377
+ if (totals.triangles > s.triangles) problems.push(`the recorded assets alone add up to about ${totals.triangles.toLocaleString('en-US')} triangles (the phone budget is ${s.triangles.toLocaleString('en-US')})`);
378
+ if (totals.textureMB > s.textureMB) problems.push(`about ${totals.textureMB} MB of picture memory in the recorded assets (the phone budget is ${s.textureMB} MB; an iPhone page dies near 100 MB)`);
379
+ if (payloadMB !== null && payloadMB > s.shippedPayloadMB) problems.push(`${payloadMB} MB of shipped payload: every file of the last build, gzipped (the budget is ${s.shippedPayloadMB} MB). It is what the game ships, not what a browser fetched before the first round: files the code never loads and music that streams are in it. Remove what is unused; the playtest measures the real first play`);
193
380
  for (const l of licence.filter((x) => x.level === 'refuse')) problems.push(`${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
194
381
  for (const f of bigFiles) problems.push(`${f.path} is ${(f.bytes / 1024 / 1024).toFixed(1)} MB in git: big files go to the studio's R2 (homie-studio storage add, then media move), raw files to art/<slug>/raw/ (git-ignored)`);
382
+ const notes = [];
383
+ if (undeclaredMapBytes) notes.push(`${(undeclaredMapBytes / 1024 / 1024).toFixed(1)} MB of the picture memory is normal and roughness maps. They are counted, because nothing says otherwise. If the game draws these models with base colour only (stylize() with a toon, flat, pixel or hand-painted material model keeps nothing else), say so and they leave the sum: "inGame": { "maps": "base" } on a record, or at the top of assets/manifest.json for every model`);
384
+ if (unused.length) notes.push(`${unused.join(', ')}: marked unused, so left out of the scene sums. Still in the shipped payload until removed (homie-studio assets remove ${id} <asset>)`);
385
+ const declared = gameBudgets(game);
386
+ if (declared) notes.push(`game.json assets.budgets raises a model's budget to ${[declared.triangles ? `${declared.triangles.toLocaleString('en-US')} triangles` : null, declared.bytes ? `${kb(declared.bytes)} KB` : null, declared.texturePx ? `${declared.texturePx} px pictures` : null, declared.tiers ? `its own numbers for ${Object.keys(declared.tiers).join(', ')}` : null].filter(Boolean).join(', ')} (never past a hard cap)`);
195
387
  const ok = rows.every((r) => r.ok) && problems.length === 0;
196
- const result = { ok, command: 'assets check', game: id, rows, totals, skinning, budgets: { ...s, skinning: SKINNING, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
388
+ const estimate = { kind: 'inventory', basis: 'the recorded files\' own triangles, draw calls and pictures, each times its declared placements; nothing was run', unmeasured: [...UNMEASURED_AT_RUNTIME], measure: 'the playtest and perf guides measure the running game (draw calls, triangles and frame time on a real page)' };
389
+ const result = { ok, command: 'assets check', scope: 'inventory-estimate', game: id, rows, totals, estimate, skinning, budgets: { ...s, skinning: SKINNING, pictures: PICTURES, game: declared, tiers: Object.fromEntries(TIERS.map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, unrecordedPictures: unrecordedPics, bigFiles, shippedPayload, firstPlay: shippedPayload, notes, problems, at: new Date().toISOString() };
197
390
  if (write) {
198
391
  const dir = join(root, '.studio', 'art', id);
199
392
  mkdirSync(dir, { recursive: true });
@@ -227,28 +420,38 @@ async function clipRow(gdir, a, tools) {
227
420
  }
228
421
  }
229
422
  }
230
- return { id: a.id, kind: 'clip', tier: 'clip', route: a.route, ok: problems.length === 0, problems, warnings, measured: measured ? { tris: 0, drawCalls: 0, materials: 0, maxTexturePx: 0, textureKB: 0, bones: 0, heightM: null, kb: measured.kb, clips: measured.clips, verbs: measured.verbs, rate: measured.rate } : null, budget: { kb: SKINNING.clipKB }, placements: 1 };
231
- }
232
-
233
- /** Whether a game's source is shared (game.json share.source is not false) and it is meant to be public. */
234
- export function publicSource(root, id) {
235
- try { const g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); return g.share?.source !== false; } catch { return true; }
423
+ return { id: a.id, kind: 'clip', tier: 'clip', route: a.route, usage: null, counted: false, ok: problems.length === 0, problems, warnings, measured: measured ? { tris: 0, drawCalls: 0, materials: 0, maxTexturePx: 0, textureKB: 0, bones: 0, heightM: null, kb: measured.kb, clips: measured.clips, verbs: measured.verbs, rate: measured.rate } : null, budget: { kb: SKINNING.clipKB }, placements: 1 };
236
424
  }
237
425
 
238
426
  /** The check in lines a person reads. */
239
427
  export function checkLines(r) {
240
- const lines = [`${r.ok ? 'PASS' : 'NOT YET'}: ${r.game}'s ${r.totals.assets} asset${r.totals.assets === 1 ? '' : 's'} against the phone budgets`];
428
+ const lines = [`${r.ok ? 'PASS' : 'NOT YET'}: ${r.game}'s ${r.totals.assets} asset${r.totals.assets === 1 ? '' : 's'} against the phone budgets (an inventory estimate from the recorded files; the running game was not measured)`];
241
429
  for (const row of r.rows) {
242
430
  const m = row.measured;
243
431
  if (row.kind === 'clip') { lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (clip library, ${row.route})${m ? `: ${m.clips} clips (${(m.verbs ?? []).join(', ')}), ${m.kb} KB, ${m.rate} keys a second` : ''}`); for (const p of row.problems) lines.push(` ${p}`); for (const w of row.warnings) lines.push(` note: ${w}`); continue; }
244
- lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}${m.bones ? `, ${m.bones} bones` : ''}` : ''}`);
432
+ // A row with nothing measured never reads "ok": it says what it could not see.
433
+ const mark = !row.ok ? 'FIX ' : row.unmeasured ? '?? ' : 'ok ';
434
+ const what = `${row.kind}, ${row.tier}, ${row.route}${row.counted === false ? ', unused' : ''}`;
435
+ const pics = m?.pictures?.length && !m.tris ? `: ${m.pictures.map((p) => `${p.px[0]} x ${p.px[1]} px`).join(', ')}, ${(m.textureKB / 1024).toFixed(1)} MB of picture memory with mipmaps, ${m.kb} KB` : null;
436
+ lines.push(` ${mark} ${row.id} (${what})${pics ?? (m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}${m.bones ? `, ${m.bones} bones` : ''}` : row.unmeasured ? `: not measured (${row.unmeasured})` : '')}`);
245
437
  for (const p of row.problems) lines.push(` ${p}`);
246
438
  for (const w of row.warnings) lines.push(` note: ${w}`);
247
439
  }
248
440
  for (const u of r.unrecorded) lines.push(` FIX ${u}: shipped with no record (homie-studio assets add ${r.game} --file ${u} --license <kind>)`);
441
+ const up = r.unrecordedPictures;
442
+ if (up?.shipped?.length) {
443
+ lines.push(` note ${up.shipped.length + (up.more ?? 0)} shipped picture${up.shipped.length + (up.more ?? 0) === 1 ? '' : 's'} with no record (about ${up.mipmappedMB} MB with mipmaps if the game loads them as textures; NOT in the sums below, and their hash and licence are unchecked):`);
444
+ for (const p of up.shipped.slice(0, 8)) lines.push(` ${p.path}${p.px ? ` (${p.px[0]} x ${p.px[1]} px)` : ` (${p.why})`}`);
445
+ if (up.shipped.length > 8 || up.more) lines.push(` and ${up.shipped.length - 8 + (up.more ?? 0)} more. Record the ones the game draws in assets/manifest.json (kind texture or sky, with a licence and SHA-256)`);
446
+ }
447
+ if (up?.notShipped?.length) lines.push(` note ${up.notShipped.length} picture${up.notShipped.length === 1 ? '' : 's'} in the game's folder that the build never ships (raw or private art: not checked, not counted): ${up.notShipped.slice(0, 4).join(', ')}${up.notShipped.length > 4 ? ', …' : ''}`);
249
448
  const s = r.budgets;
250
- lines.push(` scene: about ${r.totals.drawCalls} draw calls (budget ${s.drawCalls}), ${r.totals.triangles.toLocaleString('en-US')} triangles (${s.triangles.toLocaleString('en-US')}), ${r.totals.textureMB} MB of picture memory (${s.textureMB} MB)${r.totals.firstPlayMB !== null ? `, ${r.totals.firstPlayMB} MB first play (${s.firstPlayMB} MB)` : ', first play: build first'}`);
449
+ const payload = r.totals.shippedPayloadMB ?? r.totals.firstPlayMB ?? null;
450
+ lines.push(` inventory estimate: about ${r.totals.drawCalls} draw calls (budget ${s.drawCalls}), ${r.totals.triangles.toLocaleString('en-US')} triangles (${s.triangles.toLocaleString('en-US')}), ${r.totals.textureMB} MB of picture memory (${s.textureMB} MB)${r.totals.standaloneTextureMB ? `, ${r.totals.standaloneTextureMB} MB of it standalone textures and skies` : ''}`);
451
+ lines.push(payload !== null ? ` shipped payload: ${payload} MB, every file of the last build gzipped (${s.shippedPayloadMB ?? s.firstPlayMB} MB); not a measured first-play download` : ' shipped payload: build first');
452
+ if (r.estimate?.unmeasured?.length) lines.push(` not measured here: ${r.estimate.unmeasured.join('; ')}. ${r.estimate.measure[0].toUpperCase()}${r.estimate.measure.slice(1)}.`);
251
453
  if (r.skinning) lines.push(` skinning: a room of ${r.skinning.players} skins about ${r.skinning.vertices.toLocaleString('en-US')} vertices (budget ${r.skinning.budget.vertices.toLocaleString('en-US')}) and poses ${r.skinning.bones.toLocaleString('en-US')} bones (${r.skinning.budget.bones.toLocaleString('en-US')}) a frame on a phone; the heaviest is ${r.skinning.character}`);
454
+ for (const n of r.notes ?? []) lines.push(` note ${n}`);
252
455
  for (const l of r.licence) lines.push(` ${l.level === 'refuse' ? 'FIX ' : 'note'} licence ${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
253
456
  for (const st of r.stale) lines.push(` stale ${st.id}: made under an older ${st.decisions.map((d) => d.id).join(', ')} (homie-studio style blast shows the cost; nothing is remade by itself)`);
254
457
  for (const p of r.problems.filter((x) => !/^[a-z0-9-]+: /.test(x))) lines.push(` FIX ${p}`);
@@ -14,7 +14,7 @@ import { basename, join, relative, resolve } from 'node:path';
14
14
  import { LIMITS, checkGlb } from '../assets/safety.mjs';
15
15
  import { ASSET_ID, KINDS, licenseInfo, readManifest, recordAsset } from './asset-manifest.mjs';
16
16
  import { readDecisions } from './decisions.mjs';
17
- import { budgetsFor } from './asset-check.mjs';
17
+ import { budgetsFor, gameJson } from './asset-check.mjs';
18
18
 
19
19
  const TIER_OF_KIND = { character: 'hero', creature: 'npc', prop: 'prop', kit: 'kit', environment: 'kit' };
20
20
 
@@ -68,7 +68,9 @@ export async function importModel(root, id, file, opts = {}) {
68
68
  if (!ASSET_ID.test(asId)) throw new Error('give it an id: --as <lowercase-id>');
69
69
  const tier = opts.tier ?? TIER_OF_KIND[kind] ?? 'prop';
70
70
  const doc = readDecisions(root, id);
71
- const budgets = budgetsFor(doc);
71
+ // The game's own per-model budget (game.json assets.budgets) counts here too: a file is made as small as the game
72
+ // asks, not smaller.
73
+ const budgets = budgetsFor(doc, gameJson(root, id));
72
74
  const b = budgets[tier] ?? budgets.prop;
73
75
  // The raw file stays on this computer, beside its art job (git-ignored), unless it is there already.
74
76
  const slug = String(opts.slug ?? asId);
@@ -81,7 +83,7 @@ export async function importModel(root, id, file, opts = {}) {
81
83
  }
82
84
  const { optimiseModel, inspectModel } = await import('./optimise.mjs');
83
85
  const out = join(gdir, 'public', 'models', `${asId}.glb`);
84
- const r = await optimiseModel(path, { out, triangles: Number(opts.triangles ?? b.triangles), texture: Number(opts.texture ?? b.texturePx), height: opts.height ? Number(opts.height) : null, rigged: Boolean(opts.rigged), metal: doc?.decisions?.['style.render']?.value === 'pbr' });
86
+ const r = await optimiseModel(path, { out, triangles: Number(opts.triangles ?? b.triangles), texture: Number(opts.texture ?? b.texturePx), height: opts.height ? Number(opts.height) : null, rigged: Boolean(opts.rigged), metal: doc?.decisions?.['style.render']?.value === 'pbr', ...(opts.lo ? { lo: { ratio: Number(opts.lo) } } : {}) });
85
87
  if (!r.ok) throw new Error(`the optimised file is still refused: ${r.warnings.join('; ')}`);
86
88
  const ins = await inspectModel(out);
87
89
  const rel = relative(gdir, out).split('\\').join('/');
@@ -91,16 +93,19 @@ export async function importModel(root, id, file, opts = {}) {
91
93
  ...(opts.height ? { heightM: Number(opts.height) } : {}),
92
94
  files: [
93
95
  { role: 'model', path: rel, bytes: r.bytes, sha256: r.sha256 },
96
+ // The low copy for the far band (`--lo <ratio>`; lib/optimise.mjs): recorded beside the model with the ratio it
97
+ // was made at, so `assets redo` makes it again and a check knows the file is this asset's.
98
+ ...(r.lo ? [{ role: 'model-lo', path: relative(gdir, r.lo.path).split('\\').join('/'), bytes: r.lo.bytes, sha256: r.lo.sha256, tris: r.lo.triangles, ratio: r.lo.ratio }] : []),
94
99
  ...(opts.concept ? [{ role: 'concept', path: relative(gdir, resolve(root, opts.concept)).split('\\').join('/'), public: false }] : []),
95
100
  { role: 'raw', path: relative(gdir, rawPath).split('\\').join('/'), public: false, bytes: r.rawBytes, sha256: r.rawSha256 },
96
101
  ],
97
102
  made: { steps, ...(opts.under ? { under: opts.under } : {}) },
98
- license: { kind: info.kind, spdx: info.spdx, owner: opts.owner ?? (route === 'generated' || licKind === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, remix: info.remix, ...(route === 'generated' ? { terms: [{ who: 'fal', url: 'https://fal.ai/terms', read: '2026-10-02', label: 'Commercial use' }], notes: 'Made through fal on the studio\'s own account; fal\'s terms do not assign output ownership expressly (RIGHTS.md).' } : opts.notes ? { notes: opts.notes } : {}) },
103
+ license: { kind: info.kind, spdx: info.spdx, owner: opts.owner ?? (route === 'generated' || licKind === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, ...(route === 'generated' ? { terms: [{ who: 'fal', url: 'https://fal.ai/terms', read: '2026-10-02', label: 'Commercial use' }], notes: 'Made through fal on the studio\'s own account; fal\'s terms do not assign output ownership expressly (RIGHTS.md).' } : opts.notes ? { notes: opts.notes } : {}) },
99
104
  measured: { tris: r.after.triangles, materials: r.after.materials, drawCalls: r.after.drawCalls, textures: r.after.textures.map((t) => ({ px: t.px, format: t.mimeType, gpuKB: Math.round(t.gpuBytes / 1024) })), bones: r.after.bones, clips: r.after.clips.length, heightM: r.after.heightM, box: r.after.box, glbKB: Math.round(r.bytes / 1024), flat: r.ops.some((o) => o.startsWith('palette')) && !r.after.textures.some((t) => t.slots?.includes('normalTexture')) },
100
105
  review: { state: 'auto' },
101
106
  };
102
107
  const { entry: e, pinned } = recordAsset(root, id, entry);
103
- return { ok: true, command: 'assets add', game: id, asset: e.id, route, file: rel, raw: relative(root, rawPath), before: { tris: r.before.triangles, kb: Math.round(r.rawBytes / 1024) }, after: { tris: r.after.triangles, kb: Math.round(r.bytes / 1024), heightM: r.after.heightM }, ops: r.ops, warnings: [...r.warnings, ...(ins.validator?.errors ? [`validator: ${ins.validator.messages[0]}`] : [])], pinned, license: info.label };
108
+ return { ok: true, command: 'assets add', game: id, asset: e.id, route, file: rel, ...(opts.lo ? { lo: r.lo ? { path: relative(gdir, r.lo.path).split('\\').join('/'), triangles: r.lo.triangles, bytes: r.lo.bytes, sha256: r.lo.sha256 } : null } : {}), raw: relative(root, rawPath), before: { tris: r.before.triangles, kb: Math.round(r.rawBytes / 1024) }, after: { tris: r.after.triangles, kb: Math.round(r.bytes / 1024), heightM: r.after.heightM }, ops: r.ops, warnings: [...r.warnings, ...(ins.validator?.errors ? [`validator: ${ins.validator.messages[0]}`] : [])], pinned, license: info.label };
104
109
  }
105
110
 
106
111
 
@@ -131,6 +136,7 @@ export async function redoModel(root, id, assetId) {
131
136
  license: e.license?.kind, attribution: e.license?.attribution ?? undefined, owner: e.license?.owner ?? undefined, notes: e.license?.notes,
132
137
  steps: (e.made?.steps ?? []).filter((s) => s.what !== 'optimise'), concept: concept ? relative(root, resolve(gdir, concept.path)) : undefined,
133
138
  slug, rigged: Number(e.measured?.bones) > 0 || Number(e.measured?.clips) > 0, under,
139
+ lo: (e.files ?? []).find((f) => f.role === 'model-lo')?.ratio ?? null,
134
140
  });
135
141
  return { ...r, command: 'assets redo' };
136
142
  }