@homie-rocks/studio 0.30.2 → 0.31.1
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.
- package/CHANGELOG.md +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/lib/asset-check.mjs
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
47
|
-
export function
|
|
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
|
-
|
|
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
|
-
/**
|
|
69
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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') {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
362
|
+
const shippedPayload = shippedPayloadBytes(root, id);
|
|
181
363
|
const s = budgets.scene;
|
|
182
|
-
const
|
|
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
|
|
190
|
-
if (totals.triangles > s.triangles) problems.push(`about ${totals.triangles.toLocaleString('en-US')} triangles
|
|
191
|
-
if (totals.textureMB > s.textureMB) problems.push(
|
|
192
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/lib/asset-import.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
}
|