@bitmagic/cli 0.1.72 → 0.1.73

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.
@@ -0,0 +1,953 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * optimize-glb.mjs: the optimizing-assets skill's encoder. It compresses one GLB's textures and
4
+ * vertex buffers using a preset for its asset class, and it enforces the rules shipped games learned
5
+ * the hard way instead of trusting the next run to remember them. It runs on the shared tools in
6
+ * ~/.bitmagic/tools (glTF-Transform, meshoptimizer, sharp and gltf-validator, plus KTX-Software for
7
+ * KTX2 textures); set BM_TOOLS to use another folder. Only --audit touches the network: a HEAD
8
+ * request for each row whose size world.json does not record.
9
+ *
10
+ * node optimize-glb.mjs <in.glb> <out.glb> --preset=<class> [overrides] compress one file
11
+ * node optimize-glb.mjs --report <a.glb> [<b.glb> ...] measure, change nothing
12
+ * node optimize-glb.mjs --audit <src/work/world.json> list every asset by size
13
+ * node optimize-glb.mjs --help
14
+ *
15
+ * Rules applied on every run, whatever the flags say:
16
+ * - The vertex layout is SEPARATE. Interleaved quantized buffers crash three's WebGPU pipelines.
17
+ * - Normals and tangents are int8. int16 normals drew blotches on WebGPU.
18
+ * - Skinned or morphed files keep float32 positions. quantize() rewrites the inverse bind matrices.
19
+ * - Mesh nodes that code reads by name keep float positions and their exact TRS. These are
20
+ * BM_* engine nodes, names the game's src/ or the vendored engine/ compares node names against
21
+ * (literals, startsWith/includes arguments, regex words; any case), and --keep-float. Every
22
+ * other named mesh node whose transform quantization changes is listed, so a miss is visible.
23
+ * - Empty nodes are never pruned (markers), nodes and materials are never renamed, and textures
24
+ * are never folded into material factors (a solid-white mask is still a texture code paints).
25
+ * - Simplification locks bounding-box vertices, because the engine fits targetHeight and
26
+ * re-centres on the loaded bounds.
27
+ * - A KTX2 texture that already has the slot's encoding at no more than its edge is kept as it
28
+ * is: a re-encode is a second lossy generation. Lossless formats keep every alpha value.
29
+ * It runs from the project root (or with --code=<dir>): the node-name scan reads src/ and engine/.
30
+ * Exit code 1 means a check failed and the output must not ship.
31
+ */
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { execFileSync } from 'node:child_process';
36
+ import { createRequire } from 'node:module';
37
+ import { pathToFileURL } from 'node:url';
38
+
39
+ const PRESETS = {
40
+ 'distant-prop': {
41
+ about: 'seen from ~30 m or more: landmarks, scenery, silhouettes. Simplified; one 512 ETC1S base map with AO baked in',
42
+ simplify: 0.002, pos: 14, uv: 12, normals: 'octahedral', order: 'cache', ao: 0.6, resample: true,
43
+ tex: { base: 'etc1s:512:255', emissive: 'etc1s:512:255', normal: 'drop', orm: 'drop' },
44
+ },
45
+ 'hero-prop': {
46
+ about: 'closest approach ~10-20 m: sculpts, buildings the player passes. No simplification, float positions',
47
+ simplify: 0, pos: 0, uv: 14, normals: 'exact', order: 'cache', ao: 0, resample: true,
48
+ tex: { base: 'jpeg:1024:90', emissive: 'jpeg:1024:90', normal: 'jpeg:1024:92', orm: 'uastc:512:2' },
49
+ },
50
+ character: {
51
+ about: 'skinned or animated characters: float positions, exact normals, every animation key kept, UASTC atlas',
52
+ simplify: 0, pos: 0, uv: 16, normals: 'exact', order: 'cache', ao: 0, resample: false,
53
+ tex: { base: 'uastc:1024:4', emissive: 'uastc:1024:4', normal: 'keep', orm: 'uastc:512:2' },
54
+ },
55
+ 'code-driven': {
56
+ about: 'vehicles and props whose vertices, node positions or pixels code reads: float positions, never KTX2; base maps kept, the ORM/mask slot stored as lossless WebP (same pixels)',
57
+ simplify: 0, pos: 0, uv: 16, normals: 'exact', order: 'cache', ao: 0, resample: true, noKtx: true, pairs: true,
58
+ tex: { base: 'keep', emissive: 'keep', normal: 'keep', orm: 'webp-lossless' },
59
+ },
60
+ level: {
61
+ about: 'stages and level meshes: float positions and triangle order kept, normals int8, UVs 16-bit, clips and textures untouched',
62
+ simplify: 0, pos: 0, uv: 16, normals: 'exact', order: 'first-use', ao: 0, resample: false,
63
+ tex: { base: 'keep', emissive: 'keep', normal: 'keep', orm: 'keep' },
64
+ },
65
+ };
66
+ const FORMATS = ['etc1s', 'uastc', 'jpeg', 'webp', 'webp-lossless', 'png'];
67
+ const ENGINE_NODE_PREFIXES = ['BM_']; // anchors, slots, parts, wheels: the engine looks these up by name
68
+ const KINDS = { BaseColor: 'base', Emissive: 'emissive', Normal: 'normal', Occlusion: 'orm', MetallicRoughness: 'orm' };
69
+ /** The versions this script was tested with; references/tools.md installs exactly these. */
70
+ const TESTED = {
71
+ '@gltf-transform/core': '4.5.0', '@gltf-transform/extensions': '4.5.0', '@gltf-transform/functions': '4.5.0',
72
+ meshoptimizer: '1.2.0', sharp: '0.35.5',
73
+ };
74
+ /** Row names and file names that look like an optimized copy rather than an original. */
75
+ const VARIANT_NAME = /(?:^|[_\-.])(?:hq|mobile|lite|slim|opt|optimi[sz]ed|meshopt|ktx2?|phone|lod)\d*$/i;
76
+ const mb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MiB` : `${(n / 1024).toFixed(1)} KiB`);
77
+
78
+ const HELP = `optimize-glb.mjs: texture + vertex-buffer compression by asset class
79
+
80
+ node optimize-glb.mjs <in.glb> <out.glb> --preset=<class> [overrides]
81
+ node optimize-glb.mjs --report <a.glb> [<b.glb> ...]
82
+ node optimize-glb.mjs --audit <src/work/world.json>
83
+
84
+ Presets:
85
+ ${Object.entries(PRESETS).map(([k, p]) => ` ${k.padEnd(13)} ${p.about}`).join('\n')}
86
+
87
+ Overrides (each replaces the preset's value):
88
+ --simplify=<f> error bound as a fraction of the model's largest dimension; 0 = off
89
+ --base= --emissive= --normal= --orm=<spec>
90
+ keep | <format>[:<max edge px>][:<quality>], and drop for normal/orm;
91
+ ${FORMATS.join(', ')}; quality = etc1s qlevel 1-255,
92
+ uastc RDO lambda (0 = off), jpeg/webp 1-100
93
+ --max-texture=<px> cap every texture's edge (a phone variant: 512). A slot the preset
94
+ keeps is resized in its own format; lossless stays lossless
95
+ --ao=<0-1> AO baked into the base map when the ORM map is dropped
96
+ --pos-bits=<0|10-16> position quantization; 0 = float32 (forced for skins and morphs)
97
+ --uv-bits=<8-16>
98
+ --normals=exact|octahedral int8 either way; octahedral is ~10% smaller, ~1 degree off,
99
+ and lossy on animation
100
+ --keep-float=<a,b,c*> more mesh nodes that keep float positions and exact TRS
101
+ --code=<dir>|none source scanned for node names code reads (default: src and engine)
102
+ --meshopt=off quantization only, for an engine without the meshopt decoder
103
+ --resample=on|off drop animation keys their neighbours reproduce within 1e-4. It moved
104
+ a rider's hands by ~1 mm for 1-2% of the file: off for character
105
+ --only-default-scene drop every other scene and the nodes, meshes, skins and clips only
106
+ it used
107
+ --drop-animations drop every clip (only if the game plays none from this file)
108
+ --json print the report as JSON`;
109
+
110
+ // ---------------------------------------------------------------------------------------------- args
111
+ const argv = process.argv.slice(2);
112
+ const flag = (name) => {
113
+ const hit = argv.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
114
+ return hit === undefined ? undefined : hit.includes('=') ? hit.slice(hit.indexOf('=') + 1) : true;
115
+ };
116
+ const files = argv.filter((a) => !a.startsWith('--'));
117
+ const die = (msg, code = 2) => { console.error(`optimize-glb: ${msg}`); process.exit(code); };
118
+ if (flag('help') || argv.length === 0) { console.log(HELP); process.exit(0); }
119
+
120
+ if (flag('audit')) { await audit(files[0] ?? 'src/work/world.json'); process.exit(0); }
121
+
122
+ // The node-name scan reads ./src and ./engine. Anywhere else it finds nothing, and distant-prop
123
+ // would quantize and simplify the lamps and anchors code reads, with exit 0. Checked before the
124
+ // tools load, so it holds (and is tested) on any machine.
125
+ if (!flag('report')) {
126
+ if (flag('code') === true) die('--code takes a folder, or none');
127
+ if (typeof flag('code') === 'string' && flag('code') !== 'none' && !fs.existsSync(flag('code'))) die(`--code=${flag('code')}: no such folder`);
128
+ if (flag('code') === undefined && !fs.existsSync('src') && !fs.existsSync('engine')) {
129
+ die('no src/ or engine/ here, so the node names code reads cannot be scanned. Run from the project root, '
130
+ + 'or pass --code=<the game source folder> (--code=none only when no code reads this model)');
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------------------------- tools
135
+ const TOOLS = process.env.BM_TOOLS ?? path.join(os.homedir(), '.bitmagic/tools');
136
+ const G = path.join(TOOLS, 'gltf', 'node_modules');
137
+ const tool = (p) => import(pathToFileURL(path.join(G, p)).href); // file URLs, so Windows paths import too
138
+ if (!fs.existsSync(path.join(G, '@gltf-transform/core'))) {
139
+ die(`the shared tools are missing (${G}). Installing them is a download: ask the creator, then follow the skill's references/tools.md.`);
140
+ }
141
+ process.env.PATH = path.join(TOOLS, 'ktx/bin') + path.delimiter + process.env.PATH;
142
+ const { NodeIO, VertexLayout, Logger } = await tool('@gltf-transform/core/dist/index.js');
143
+ const X = await tool('@gltf-transform/extensions/dist/index.js');
144
+ const { dedup, weld, prune, quantize, reorder, resample, compactPrimitive, getBounds } = await tool('@gltf-transform/functions/dist/index.js');
145
+ const { MeshoptEncoder } = await tool('meshoptimizer/meshopt_encoder.js');
146
+ const { MeshoptDecoder } = await tool('meshoptimizer/meshopt_decoder.mjs');
147
+ const { MeshoptSimplifier } = await tool('meshoptimizer/meshopt_simplifier.js');
148
+ const sharp = (await tool('sharp/dist/index.mjs')).default;
149
+ await Promise.all([MeshoptEncoder.ready, MeshoptDecoder.ready, MeshoptSimplifier.ready]);
150
+
151
+ const notes = [];
152
+ const ktxState = { checked: false }; // `ktx --version` is read once, on first use
153
+ /** glTF-Transform's own warnings, with the harmless UV one turned into a single plain note. */
154
+ class NoteLogger extends Logger {
155
+ warn(text) {
156
+ const uv = /Skipping (TEXCOORD_\d+); out of \[0,1\] range/.exec(text);
157
+ if (!uv) return super.warn(text);
158
+ const note = `${uv[1]} leaves 0-1 (tiling UVs) on some meshes, so those stay float. Harmless: it is what glTF-Transform's "Skipping" line meant`;
159
+ if (!notes.includes(note)) notes.push(note);
160
+ }
161
+ }
162
+ const io = new NodeIO().registerExtensions(X.ALL_EXTENSIONS)
163
+ .registerDependencies({ 'meshopt.encoder': MeshoptEncoder, 'meshopt.decoder': MeshoptDecoder });
164
+ io.setVertexLayout(VertexLayout.SEPARATE).setLogger(new NoteLogger(Logger.Verbosity.WARN));
165
+ const PRUNE = { keepLeaves: true, keepAttributes: true, keepExtras: true, keepSolidTextures: true };
166
+
167
+ if (flag('report')) {
168
+ for (const f of files) printStats(f, stats(await io.read(f), fs.statSync(f).size));
169
+ process.exit(0);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------------------------- settings
173
+ const [src, dst] = files;
174
+ const P = PRESETS[flag('preset')];
175
+ if (!src || !dst || !P) die(`usage: optimize-glb.mjs <in.glb> <out.glb> --preset=${Object.keys(PRESETS).join('|')} (--help)`);
176
+ if (path.resolve(src) === path.resolve(dst)) die('refusing to overwrite the input: keep the original as the fallback.');
177
+ const num = (name, fallback, lo, hi) => {
178
+ const v = flag(name); if (v === undefined) return fallback;
179
+ const n = Number(v); if (!Number.isFinite(n) || n < lo || n > hi) die(`--${name} must be ${lo}-${hi}`);
180
+ return n;
181
+ };
182
+ const S = {
183
+ simplify: num('simplify', P.simplify, 0, 0.1), ao: num('ao', P.ao, 0, 1),
184
+ pos: num('pos-bits', P.pos, 0, 16), uv: num('uv-bits', P.uv, 8, 16), maxTex: num('max-texture', 0, 4, 16384),
185
+ normals: flag('normals') ?? P.normals, order: P.order, meshopt: flag('meshopt') !== 'off',
186
+ resample: flag('resample') === undefined ? P.resample : flag('resample') === 'on',
187
+ tex: Object.fromEntries(['base', 'emissive', 'normal', 'orm'].map((k) => [k, parseSpec(flag(k) ?? P.tex[k], k)])),
188
+ /** Slots given on the command line: an explicit KTX2 spec re-encodes a KTX2 source that already matches. */
189
+ explicit: new Set(['base', 'emissive', 'normal', 'orm'].filter((k) => flag(k) !== undefined)),
190
+ };
191
+ if (S.pos > 0 && S.pos < 10) die('--pos-bits must be 0 (float) or 10-16');
192
+ if (!['exact', 'octahedral'].includes(S.normals)) die('--normals must be exact or octahedral');
193
+ if (flag('resample') !== undefined && !['on', 'off'].includes(flag('resample'))) die('--resample must be on or off');
194
+ function parseSpec(spec, slot) {
195
+ if (spec === 'drop' && (slot === 'base' || slot === 'emissive')) die(`--${slot}=drop: only --normal and --orm can be dropped`);
196
+ if (spec === 'keep' || spec === 'drop') return { kind: spec };
197
+ const [format, edge, quality] = String(spec).split(':');
198
+ if (!FORMATS.includes(format)) die(`--${slot}=${spec}: format must be keep, drop or one of ${FORMATS.join(', ')}`);
199
+ if (P.noKtx && (format === 'etc1s' || format === 'uastc')) die(`--${slot}=${spec}: this preset's pixels are read by code; KTX2 makes them unreadable`);
200
+ return { kind: 'encode', format, edge: edge ? Number(edge) : 0, quality: quality === undefined ? undefined : Number(quality) };
201
+ }
202
+ checkToolVersions();
203
+
204
+ // ---------------------------------------------------------------------------------------------- run
205
+ const before = await io.read(src);
206
+ const statsBefore = stats(before, fs.statSync(src).size);
207
+ const doc = await io.read(src);
208
+ const root = doc.getRoot();
209
+ const scene = root.getDefaultScene() ?? root.listScenes()[0];
210
+ const carried = statsBefore.extensions.filter((e) => e === 'KHR_mesh_quantization' || e === 'KHR_texture_basisu');
211
+ if (statsBefore.extensions.includes('EXT_meshopt_compression')) {
212
+ notes.push('warning: the input is already EXT_meshopt_compression, so it is probably an optimized variant. Start from the original '
213
+ + 'asset: a second pass encodes lossy textures again and barely moves the bytes');
214
+ } else if (carried.length) {
215
+ notes.push(`the input already carries ${carried.join(' and ')} (some exporters quantize, library characters ship KTX2). `
216
+ + 'If this file is itself an optimized variant, start from its original instead');
217
+ }
218
+
219
+ // 1. Clean-up that cannot change a pixel.
220
+ const code = scanCode();
221
+ const clipsIn = root.listAnimations().map((a) => a.getName());
222
+ if (flag('only-default-scene')) await dropOtherScenes();
223
+ const namesIn = nodeNames(doc); // after the scene drop: what the output must still name
224
+ if (flag('drop-animations')) {
225
+ for (const a of root.listAnimations()) { a.listChannels().forEach((c) => c.dispose()); a.listSamplers().forEach((s) => s.dispose()); a.dispose(); }
226
+ notes.push(`dropped every animation clip${clipsIn.length ? `: ${clipsIn.filter(Boolean).join(', ')}` : ''}`);
227
+ }
228
+ warnClipsCodeNames();
229
+ await doc.transform(dedup({ keepUniqueNames: true }), weld(), ...(S.resample && root.listAnimations().length ? [resample()] : []));
230
+
231
+ // 2. What must stay exact.
232
+ const deforming = root.listSkins().length > 0 || root.listMeshes().some((m) => m.listPrimitives().some((p) => p.listTargets().length));
233
+ if (deforming && S.pos) { S.pos = 0; notes.push('positions kept float32: the file has skins or morph targets'); }
234
+ // Keeping a node exact only changes anything when positions are quantized or meshes simplified.
235
+ const keep = S.pos || S.simplify ? keepFloatNodes(doc, code.patterns) : new Map();
236
+ for (const [n, why] of keep) notes.push(`kept exact (not simplified, float positions, same TRS): ${n.getName()} (${why})`);
237
+ for (const f of code.glbParsers) {
238
+ notes.push(`warning: ${f} reads GLB bytes itself and never mentions meshopt. The engine's decoder does not reach it: give it the `
239
+ + 'meshopt decoder and make it read normalized int8/int16 attributes, or use --meshopt=off and still check the int8 normals');
240
+ }
241
+ if (S.normals === 'octahedral' && root.listAnimations().length) notes.push('warning: octahedral mode also filters animation (quaternion 16-bit, translation/scale 12-bit)');
242
+ const droppedKinds = new Set([...(S.tex.normal.kind === 'drop' ? ['Normal'] : []), ...(S.tex.orm.kind === 'drop' ? ['Occlusion', 'MetallicRoughness'] : [])]);
243
+ const slotsIn = root.listMaterials().flatMap((mat) => Object.keys(KINDS).filter((k) => mat[`get${k}Texture`]()).map((kind) => ({ mat, kind })));
244
+ const sizesIn = new Map(root.listMaterials().map((mat) => [mat, textureSizes(mat)]));
245
+
246
+ // 3. Simplify.
247
+ if (S.simplify > 0) simplifyAll();
248
+
249
+ // 4. Textures (before quantization: gutter fill and AO bake read float UVs).
250
+ await processTextures();
251
+ await doc.transform(prune(PRUNE));
252
+
253
+ // 5. Quantize. Kept nodes are restored afterwards, which is simpler than excluding them.
254
+ const snap = [...keep.keys()].map((n) => ({ n, t: n.getTranslation(), r: n.getRotation(), s: n.getScale(), mesh: n.getMesh(),
255
+ pos: n.getMesh().listPrimitives().map((p) => [p, p.getAttribute('POSITION'), p.getMaterial()]) }));
256
+ const trs = (n) => [...n.getTranslation(), ...n.getRotation(), ...n.getScale()];
257
+ const named = root.listNodes().filter((n) => n.getName() && n.getMesh() && !keep.has(n)).map((n) => ({ n, mesh: n.getMesh(), trs: trs(n) }));
258
+ const semantics = ['NORMAL', 'TANGENT', 'TEXCOORD_\\d+', 'JOINTS_\\d+', 'WEIGHTS_\\d+', ...(S.pos ? ['POSITION'] : [])];
259
+ await doc.transform(quantize({ pattern: new RegExp(`^(${semantics.join('|')})$`), quantizePosition: S.pos || 14,
260
+ quantizeNormal: 8, quantizeTexcoord: S.uv, quantizeWeight: 8, cleanup: false }));
261
+ for (const k of snap) {
262
+ if (k.n.getMesh() !== k.mesh) { // quantize() moved the mesh onto a new unnamed child: move it back
263
+ const child = k.n.listChildren().find((c) => c.getMesh() === k.mesh && !c.getName());
264
+ child?.setMesh(null); child?.dispose(); k.n.setMesh(k.mesh);
265
+ }
266
+ k.n.setTranslation(k.t).setRotation(k.r).setScale(k.s);
267
+ for (const [p, a, m] of k.pos) p.setAttribute('POSITION', a).setMaterial(m); // quantize() rescales volume thickness
268
+ }
269
+ const moved = named.filter((x) => x.n.getMesh() !== x.mesh || trs(x.n).some((v, i) => Math.abs(v - x.trs[i]) > 1e-6)).map((x) => x.n.getName());
270
+ if (moved.length) {
271
+ notes.push(`positions quantized: ${moved.length} named mesh node(s) now carry a different transform (origin); the mesh still draws in `
272
+ + `place: ${moved.slice(0, 20).join(', ')}${moved.length > 20 ? `, and ${moved.length - 20} more` : ''}. If code reads the position `
273
+ + 'of any of them (lamps, anchors, attach points), rerun with --keep-float=<names>');
274
+ }
275
+ await doc.transform(prune(PRUNE), dedup({ keepUniqueNames: true }));
276
+ // quantize() declares KHR_mesh_quantization only when positions are quantized; int8 normals need it too.
277
+ const needsQuant = root.listMeshes().flatMap((m) => m.listPrimitives()).some((p) => ['POSITION', 'NORMAL', 'TANGENT']
278
+ .some((sem) => p.getAttribute(sem) && p.getAttribute(sem).getComponentType() !== 5126));
279
+ if (needsQuant) doc.createExtension(X.KHRMeshQuantization).setRequired(true);
280
+ const liveSlots = slotsIn.filter(({ mat, kind }) => !mat.isDisposed() && !droppedKinds.has(kind));
281
+ const lostSlots = liveSlots.filter(({ mat, kind }) => !mat[`get${kind}Texture`]()).map(({ mat, kind }) => `${mat.getName() || 'material'}.${kind}`);
282
+ const unpaired = P.pairs ? pairingBreaks() : [];
283
+
284
+ // 6. Vertex order, then meshopt.
285
+ if (S.order === 'cache') await doc.transform(reorder({ encoder: MeshoptEncoder, target: 'size' }));
286
+ else renumberByFirstUse();
287
+ if (S.meshopt) {
288
+ doc.createExtension(X.EXTMeshoptCompression).setRequired(true).setEncoderOptions({
289
+ method: S.normals === 'octahedral' ? X.EXTMeshoptCompression.EncoderMethod.FILTER : X.EXTMeshoptCompression.EncoderMethod.QUANTIZE });
290
+ } else {
291
+ root.listExtensionsUsed().find((e) => e.extensionName === 'EXT_meshopt_compression')?.dispose();
292
+ }
293
+ await io.write(dst, doc);
294
+
295
+ // 7. Check what was written, as a loader will read it.
296
+ const bytes = fs.readFileSync(dst);
297
+ const out = await io.read(dst);
298
+ const statsAfter = stats(out, bytes.length);
299
+ const checks = checkOutput(out, bytes);
300
+ checks.push(await validate(bytes, fs.readFileSync(src)));
301
+ if (flag('json')) console.log(JSON.stringify({ src, dst, preset: flag('preset'), before: statsBefore, after: statsAfter, notes, checks }, null, 1));
302
+ else printReport();
303
+ process.exit(checks.some((c) => c.fail) ? 1 : 0);
304
+
305
+ // ============================================================================================== steps
306
+
307
+ /**
308
+ * --only-default-scene: the other scenes go, and so does every node only they reached (a library
309
+ * character's second "static eyes" rig), with the meshes, skins and animation channels only those
310
+ * nodes used. Nodes the default scene reaches (children, skin joints) always stay.
311
+ */
312
+ async function dropOtherScenes() {
313
+ const reached = new Set();
314
+ const visit = (n) => {
315
+ if (reached.has(n)) return;
316
+ reached.add(n);
317
+ n.listChildren().forEach(visit);
318
+ const skin = n.getSkin();
319
+ if (skin) { skin.listJoints().forEach(visit); if (skin.getSkeleton()) visit(skin.getSkeleton()); }
320
+ };
321
+ scene.listChildren().forEach(visit);
322
+ const others = root.listScenes().filter((s) => s !== scene);
323
+ if (!others.length) return;
324
+ const orphans = new Set();
325
+ const collect = (n) => { if (orphans.has(n) || reached.has(n)) return; orphans.add(n); n.listChildren().forEach(collect); };
326
+ for (const s of others) { s.listChildren().forEach(collect); s.dispose(); }
327
+ const names = [...orphans].map((n) => n.getName()).filter(Boolean);
328
+ for (const n of orphans) n.dispose();
329
+ const clips = [];
330
+ for (const a of root.listAnimations()) {
331
+ for (const c of a.listChannels()) if (!c.getTargetNode()) c.dispose();
332
+ const used = new Set(a.listChannels().map((c) => c.getSampler()));
333
+ for (const s of a.listSamplers()) if (!used.has(s)) s.dispose();
334
+ if (!a.listChannels().length) { clips.push(a.getName() || '(unnamed)'); a.dispose(); }
335
+ }
336
+ await doc.transform(prune(PRUNE)); // their meshes, skins, materials and textures, before anything encodes them
337
+ notes.push(`dropped ${others.length} non-default scene(s) and the ${orphans.size} node(s) only they used`
338
+ + `${names.length ? `: ${names.slice(0, 8).join(', ')}${names.length > 8 ? `, and ${names.length - 8} more` : ''}` : ''}`
339
+ + `${clips.length ? `; and the ${clips.length} clip(s) that animated only those nodes: ${clips.join(', ')}` : ''}`);
340
+ }
341
+
342
+ /**
343
+ * A clip the output no longer has, whose name code compares against (the same scan as node names;
344
+ * exact matches, because a clip is looked up by its whole name: findByName, clipAction, ===).
345
+ */
346
+ function warnClipsCodeNames() {
347
+ const left = new Set(root.listAnimations().map((a) => a.getName()));
348
+ for (const name of new Set(clipsIn.filter((n) => n && !left.has(n)))) {
349
+ const hit = code.patterns.find((p) => p.mode === 'exact' && p.text === (p.ci ? name.toLowerCase() : name));
350
+ if (hit) {
351
+ notes.push(`warning: ${hit.where} names the clip "${name}", which this output no longer has. If that code plays it on this `
352
+ + 'model, keep the clip: rerun without the flag that dropped it');
353
+ }
354
+ }
355
+ }
356
+
357
+ /** Mesh nodes whose position or TRS code relies on: engine prefixes, --keep-float, names code compares against. */
358
+ function keepFloatNodes(d, patterns) {
359
+ const kept = new Map();
360
+ const extra = String(flag('keep-float') ?? '').split(',').filter(Boolean);
361
+ const sanitize = (s) => s.replace(/\s/g, '_').replace(/[[\].:/]/g, ''); // three's PropertyBinding.sanitizeNodeName
362
+ for (const n of d.getRoot().listNodes()) {
363
+ const name = n.getName();
364
+ if (!n.getMesh() || !name) continue;
365
+ let why = ENGINE_NODE_PREFIXES.find((p) => name.startsWith(p)) && 'engine name prefix';
366
+ why ||= extra.find((e) => (e.endsWith('*') ? name.startsWith(e.slice(0, -1)) : name === e)) && '--keep-float';
367
+ for (const raw of why ? [] : new Set([name, sanitize(name)])) {
368
+ const hit = patterns.find((p) => {
369
+ const cand = p.ci ? raw.toLowerCase() : raw;
370
+ return p.mode === 'exact' ? cand === p.text : p.mode === 'prefix' ? cand.startsWith(p.text) : cand.includes(p.text);
371
+ });
372
+ if (hit) { why = `${hit.mode === 'exact' ? '' : `${hit.mode} `}"${hit.text}"${hit.ci ? ' (any case)' : ''} in ${hit.where}`; break; }
373
+ }
374
+ if (why) kept.set(n, why);
375
+ }
376
+ // A mesh shared with other nodes stays exact for all of them, or their transforms would disagree.
377
+ for (const n of [...kept.keys()]) for (const p of n.getMesh().listParents()) if (p.propertyType === 'Node' && !kept.has(p)) kept.set(p, `shares a mesh with ${n.getName()}`);
378
+ return kept;
379
+ }
380
+
381
+ /** Source files under a folder, as [path, text]. */
382
+ function sourceFiles(dir) {
383
+ const found = [];
384
+ const walk = (d, depth) => {
385
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
386
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === '__tests__') continue;
387
+ const p = path.join(d, e.name);
388
+ if (e.isDirectory()) { if (depth < 12) walk(p, depth + 1); continue; }
389
+ if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) found.push([p, fs.readFileSync(p, 'utf8')]);
390
+ }
391
+ };
392
+ if (fs.existsSync(dir)) walk(dir, 0);
393
+ return found;
394
+ }
395
+
396
+ /**
397
+ * What code compares node names against. From the game's src/: every string literal (exact),
398
+ * startsWith/includes/endsWith arguments and template heads (prefix or substring), and the words
399
+ * of every regex literal (substring; prefix when anchored). From the vendored engine/: the same,
400
+ * but only where a name is being compared, because its other strings are not node names. A
401
+ * comparison ignores case when the code does: a toLowerCase()/toUpperCase() just before it, or a
402
+ * regex with the i flag. A superset only costs bytes, a miss moves a lamp; but an exact literal
403
+ * matched in any case would keep a landmark's whole hull unsimplified because some object has the
404
+ * key 'trawler'. Also returns the src/ files that parse GLB bytes themselves.
405
+ */
406
+ function scanCode() {
407
+ const dirs = flag('code') === 'none' ? [] : flag('code') ? [[flag('code'), false]] : [['src', false], ['engine', true]];
408
+ const patterns = new Map();
409
+ const glbParsers = [];
410
+ const add = (text, mode, ci, where) => {
411
+ const t = ci ? text.trim().toLowerCase() : text.trim();
412
+ if (t.length < 3 || t.length > 200 || !/[a-z]/i.test(t)) return;
413
+ if (!patterns.has(`${mode}:${ci}:${t}`)) patterns.set(`${mode}:${ci}:${t}`, { text: t, mode, ci, where });
414
+ };
415
+ const regexWords = (body, ci, where) => {
416
+ const anchored = body.startsWith('^');
417
+ const plain = body.replace(/\[(?:\\.|[^\]\\])*\]/g, ' ').replace(/\\[dDwWsSbB]/g, ' ').replace(/\\(.)/g, '$1');
418
+ const words = plain.match(/[A-Za-z][A-Za-z0-9_]{2,}/g) ?? [];
419
+ words.forEach((w, i) => add(w, anchored && i === 0 && plain.slice(1).startsWith(w) ? 'prefix' : 'substring', ci, where));
420
+ };
421
+ for (const [dir, lookupsOnly] of dirs) {
422
+ for (const [file, text] of sourceFiles(dir)) {
423
+ const where = path.relative('.', file);
424
+ for (const m of text.matchAll(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g)) {
425
+ const lit = m[2];
426
+ const lead = text.slice(Math.max(0, m.index - 48), m.index);
427
+ const ctx = /\.startsWith\(\s*$/.test(lead) ? 'prefix'
428
+ : /\.(includes|indexOf|lastIndexOf|endsWith|search|match)\(\s*$/.test(lead) ? 'substring'
429
+ : /RegExp\(\s*$/.test(lead) ? 'regex'
430
+ : /(getObjectByName|getNodeByName|name\S{0,24}\s*[!=]==?)\s*\(?\s*$/.test(lead) ? 'exact' : '';
431
+ if (lookupsOnly && !ctx) continue;
432
+ const ci = /to(Lower|Upper)Case\(\)[^;]{0,24}$/.test(lead);
433
+ if (ctx === 'regex') { regexWords(lit, /^\s*,\s*['"`][a-z]*i/.test(text.slice(m.index + m[0].length, m.index + m[0].length + 12)), where); continue; }
434
+ if (m[1] === '`' && lit.includes('${')) { add(lit.split('${')[0], 'prefix', ci, where); continue; } // `Emissive_${x}` reads every Emissive_*
435
+ add(lit, ctx || (/[_\-.:]$/.test(lit) ? 'prefix' : 'exact'), ci, where);
436
+ }
437
+ for (const m of text.matchAll(/(?:^|[=(,:!&|?{};[]|\breturn)\s*\/((?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n[*])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n[])*)\/([dgimsuvy]*)/gm)) {
438
+ if (lookupsOnly && !/name/i.test(text.slice(m.index, m.index + m[0].length + 40))) continue;
439
+ regexWords(m[1], m[2].includes('i'), where);
440
+ }
441
+ if (!lookupsOnly && /bufferViews|0x46546c67|0x4e4f534a/i.test(text) && !/meshopt/i.test(text)) glbParsers.push(where);
442
+ }
443
+ }
444
+ return { patterns: [...patterns.values()], glbParsers };
445
+ }
446
+
447
+ /** Error-bounded simplification, weighting normals (0.1) so shading detail survives, bounds locked. */
448
+ function simplifyAll() {
449
+ const b = getBounds(scene);
450
+ const extent = Math.max(...[0, 1, 2].map((k) => b.max[k] - b.min[k]));
451
+ const keptMeshes = new Set([...keep.keys()].map((n) => n.getMesh()));
452
+ for (const mesh of root.listMeshes()) {
453
+ if (keptMeshes.has(mesh)) continue;
454
+ const scales = mesh.listParents().filter((p) => p.propertyType === 'Node').map(worldScale);
455
+ const scale = scales.length ? Math.max(1e-9, ...scales) : 1;
456
+ for (const prim of mesh.listPrimitives()) {
457
+ const P = prim.getAttribute('POSITION'), I = prim.getIndices();
458
+ if (prim.getMode() !== 4 || !I || prim.listTargets().length || prim.getAttribute('JOINTS_0') || P.getComponentType() !== 5126) continue;
459
+ const pos = P.getArray(), n = P.getCount(), before = I.getCount() / 3;
460
+ const lock = new Uint8Array(n);
461
+ for (let k = 0; k < 3; k++) {
462
+ let lo = Infinity, hi = -Infinity;
463
+ for (let i = 0; i < n; i++) { lo = Math.min(lo, pos[i * 3 + k]); hi = Math.max(hi, pos[i * 3 + k]); }
464
+ for (let i = 0; i < n; i++) if (pos[i * 3 + k] === lo || pos[i * 3 + k] === hi) lock[i] = 1;
465
+ }
466
+ const N = prim.getAttribute('NORMAL');
467
+ const attr = N && N.getComponentType() === 5126 ? new Float32Array(N.getArray()) : new Float32Array(0);
468
+ const [idx] = MeshoptSimplifier.simplifyWithAttributes(new Uint32Array(I.getArray()), new Float32Array(pos), 3, attr,
469
+ attr.length ? 3 : 0, attr.length ? [0.1, 0.1, 0.1] : [], lock, 0, S.simplify * extent / scale, ['ErrorAbsolute']);
470
+ if (idx.length / 3 >= before * 0.98) continue;
471
+ prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(idx).setBuffer(I.getBuffer()));
472
+ compactPrimitive(prim);
473
+ notes.push(`simplified ${mesh.getName() || 'mesh'}: ${before} -> ${idx.length / 3} triangles`);
474
+ }
475
+ }
476
+ }
477
+ function worldScale(node) {
478
+ const m = node.getWorldMatrix();
479
+ return Math.max(Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]));
480
+ }
481
+
482
+ /** Level geometry: vertices renumbered by first use; triangle order (draw order of coplanar faces) untouched. */
483
+ function renumberByFirstUse() {
484
+ for (const prim of root.listMeshes().flatMap((m) => m.listPrimitives())) {
485
+ const I = prim.getIndices(); if (!I) continue;
486
+ const n = prim.getAttribute('POSITION').getCount(), ia = I.getArray();
487
+ const map = new Int32Array(n).fill(-1); let next = 0;
488
+ const ni = new ia.constructor(ia.length);
489
+ for (let t = 0; t < ia.length; t++) { if (map[ia[t]] < 0) map[ia[t]] = next++; ni[t] = map[ia[t]]; }
490
+ for (const sem of prim.listSemantics()) { // fresh accessors: after dedup, primitives can share one
491
+ const a = prim.getAttribute(sem), es = a.getElementSize(), arr = a.getArray(), o = new arr.constructor(next * es);
492
+ for (let v = 0; v < n; v++) if (map[v] >= 0) for (let c = 0; c < es; c++) o[map[v] * es + c] = arr[v * es + c];
493
+ prim.setAttribute(sem, doc.createAccessor().setType(a.getType()).setArray(o).setNormalized(a.getNormalized()).setBuffer(a.getBuffer()));
494
+ }
495
+ prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(ni).setBuffer(I.getBuffer()));
496
+ }
497
+ // Only the replaced accessors go; nodes are never pruned.
498
+ for (const a of root.listAccessors()) if (a.listParents().every((p) => p.propertyType === 'Root')) a.dispose();
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------------------------- textures
502
+ function textureUses(d) {
503
+ const uses = new Map();
504
+ for (const mat of d.getRoot().listMaterials()) {
505
+ for (const [kind, slot] of Object.entries(KINDS)) {
506
+ const tex = mat[`get${kind}Texture`](); if (!tex) continue;
507
+ const info = mat[`get${kind}TextureInfo`]();
508
+ const u = uses.get(tex) ?? { slots: new Set(), refs: [] };
509
+ u.slots.add(slot); u.refs.push({ mat, kind, texCoord: info.getTexCoord(), transformed: !!info.getExtension('KHR_texture_transform') });
510
+ uses.set(tex, u);
511
+ }
512
+ }
513
+ for (const u of uses.values()) u.slot = u.slots.size === 1 ? [...u.slots][0] : [...u.slots].every((s) => s === 'base' || s === 'emissive') ? 'base' : 'other';
514
+ return uses;
515
+ }
516
+ /** kind -> "WxH" for every texture a material uses, for the pairing check. */
517
+ function textureSizes(mat) {
518
+ return new Map(Object.keys(KINDS).map((k) => [k, mat[`get${k}Texture`]()?.getSize()?.join('x')]).filter(([, s]) => s));
519
+ }
520
+ /** code-driven: textures of one material that had the same size (a base and the mask code pairs with it) must still match. */
521
+ function pairingBreaks() {
522
+ const bad = [];
523
+ for (const [mat, was] of sizesIn) {
524
+ if (mat.isDisposed()) continue;
525
+ const now = textureSizes(mat);
526
+ const kinds = [...was.keys()].filter((k) => now.has(k));
527
+ for (let i = 0; i < kinds.length; i++) for (let j = i + 1; j < kinds.length; j++) {
528
+ const [a, b] = [kinds[i], kinds[j]];
529
+ if (was.get(a) === was.get(b) && now.get(a) !== now.get(b)) bad.push(`${mat.getName() || 'material'} ${a} ${now.get(a)} vs ${b} ${now.get(b)}`);
530
+ }
531
+ }
532
+ return bad;
533
+ }
534
+
535
+ /** A slot the preset keeps, resized under --max-texture: the texture's own format, lossless staying lossless. */
536
+ function ownFormat(tex, slot) {
537
+ const mime = tex.getMimeType(), img = tex.getImage();
538
+ if (mime === 'image/jpeg') return { kind: 'encode', format: 'jpeg', edge: 0, quality: slot === 'normal' ? 92 : 90 };
539
+ if (mime === 'image/png') return { kind: 'encode', format: 'png', edge: 0 };
540
+ if (mime === 'image/webp') return { kind: 'encode', format: webpIsLossless(img) ? 'webp-lossless' : 'webp', edge: 0, quality: 90 };
541
+ if (mime === 'image/ktx2' && !P.noKtx) {
542
+ return ktx2Encoding(tex) === 'etc1s' ? { kind: 'encode', format: 'etc1s', edge: 0, quality: 255 } : { kind: 'encode', format: 'uastc', edge: 0, quality: 2 };
543
+ }
544
+ return null;
545
+ }
546
+ /** 'etc1s' or 'uastc' for a Basis Universal KTX2 image, otherwise null. */
547
+ function ktx2Encoding(tex) {
548
+ const img = tex.getImage();
549
+ if (tex.getMimeType() !== 'image/ktx2' || !img || img.byteLength < 80) return null;
550
+ const v = new DataView(img.buffer, img.byteOffset, img.byteLength);
551
+ if (v.getUint32(44, true) === 1) return 'etc1s'; // supercompressionScheme 1: BasisLZ
552
+ const dfd = v.getUint32(48, true); // the data format descriptor's colour model
553
+ return dfd + 16 <= img.byteLength && v.getUint8(dfd + 12) === 166 ? 'uastc' : null; // KHR_DF_MODEL_UASTC
554
+ }
555
+ function webpIsLossless(img) {
556
+ const b = Buffer.from(img.buffer, img.byteOffset, img.byteLength);
557
+ for (let o = 12; o + 8 <= b.length; o += 8 + b.readUInt32LE(o + 4) + (b.readUInt32LE(o + 4) & 1)) {
558
+ const cc = b.toString('latin1', o, o + 4);
559
+ if (cc === 'VP8L') return true;
560
+ if (cc === 'VP8 ') return false;
561
+ }
562
+ return false;
563
+ }
564
+
565
+ async function processTextures() {
566
+ const uses = textureUses(doc);
567
+ const decoded = new Map();
568
+ const get = async (tex) => decoded.get(tex) ?? decoded.set(tex, await decode(tex)).get(tex);
569
+ const prims = (mat) => root.listMeshes().flatMap((m) => m.listPrimitives()).filter((p) => p.getMaterial() === mat);
570
+
571
+ // ORM dropped: constant roughness/metalness from the map's mean, and its AO kept for the base map
572
+ // when both maps read the same UVs the same way (the bake copies texel for texel).
573
+ const aoFor = new Map();
574
+ const keptKtx2 = [];
575
+ if (S.tex.orm.kind === 'drop') {
576
+ for (const mat of root.listMaterials()) {
577
+ const mr = mat.getMetallicRoughnessTexture(), occ = mat.getOcclusionTexture(), base = mat.getBaseColorTexture();
578
+ if (mr) {
579
+ const img = await get(mr), cov = coverage(img.w, img.h, [{ mat, texCoord: mat.getMetallicRoughnessTextureInfo().getTexCoord() }]);
580
+ const any = cov?.some(Boolean);
581
+ let g = 0, bl = 0, c = 0;
582
+ for (let i = 0; i < img.w * img.h; i++) if (!any || cov[i]) { g += img.px[i * 4 + 1]; bl += img.px[i * 4 + 2]; c++; }
583
+ const rough = +(mat.getRoughnessFactor() * g / c / 255).toFixed(2), metal = +(mat.getMetallicFactor() * bl / c / 255).toFixed(2);
584
+ mat.setRoughnessFactor(rough).setMetallicFactor(metal);
585
+ notes.push(`${mat.getName() || 'material'}: ORM dropped, roughness ${rough}, metalness ${metal}${metal > 0.1 ? ' (warning: metal loses its map; consider --orm=uastc:512)' : ''}`);
586
+ }
587
+ if (occ && base && S.ao > 0) {
588
+ const oi = mat.getOcclusionTextureInfo(), bi = mat.getBaseColorTextureInfo();
589
+ const why = oi.getTexCoord() !== bi.getTexCoord() ? `the occlusion map reads TEXCOORD_${oi.getTexCoord()} and the base map TEXCOORD_${bi.getTexCoord()}`
590
+ : oi.getExtension('KHR_texture_transform') || bi.getExtension('KHR_texture_transform') ? 'a KHR_texture_transform moves one of them'
591
+ : aoFor.has(base) && aoFor.get(base).occ !== occ ? 'another material pairs the same base map with a different occlusion map' : '';
592
+ if (why) { aoFor.set(base, { skip: true, occ }); notes.push(`AO not baked into ${base.getName() || 'the base map'}: ${why}. Check the dark-scene A/B`); }
593
+ else if (!aoFor.get(base)?.skip) aoFor.set(base, { occ, img: await get(occ) });
594
+ }
595
+ mat.setMetallicRoughnessTexture(null).setOcclusionTexture(null);
596
+ }
597
+ }
598
+ if (S.tex.normal.kind === 'drop') {
599
+ for (const mat of root.listMaterials()) {
600
+ if (!mat.getNormalTexture()) continue;
601
+ mat.setNormalTexture(null);
602
+ for (const p of prims(mat)) p.setAttribute('TANGENT', null);
603
+ }
604
+ }
605
+
606
+ for (const [tex, u] of uses) {
607
+ if (!u.refs.some((r) => r.mat[`get${r.kind}Texture`]() === tex)) continue; // every slot it filled was dropped
608
+ let spec = u.slot === 'other' ? { kind: 'keep' } : S.tex[u.slot];
609
+ if (u.slot === 'other') notes.push(`texture ${tex.getName() || '?'} kept: used in several different slots`);
610
+ const [w0, h0] = tex.getSize() ?? [0, 0];
611
+ if (spec.kind === 'keep' && S.maxTex && Math.max(w0, h0) > S.maxTex) {
612
+ const own = ownFormat(tex, u.slot);
613
+ if (own) { spec = own; notes.push(`texture ${tex.getName() || '?'} (${u.slot}, kept by the preset) resized to the ${S.maxTex} cap as ${own.format}`); }
614
+ else notes.push(`warning: texture ${tex.getName() || '?'} stays ${w0}x${h0}, over --max-texture: this preset cannot re-encode ${tex.getMimeType()}`);
615
+ }
616
+ const ao = aoFor.get(tex);
617
+ if (spec.kind !== 'encode' && ao?.img) notes.push(`AO not baked into ${tex.getName() || 'the base map'}: it is kept as it is`);
618
+ if (spec.kind !== 'encode') continue;
619
+ // A library character's atlas is already UASTC: re-encoding it is a second lossy generation.
620
+ const has = ktx2Encoding(tex);
621
+ if (has === spec.format && Math.max(w0, h0) <= Math.min(spec.edge || Infinity, S.maxTex || Infinity) && !ao?.img && !S.explicit.has(u.slot)) {
622
+ keptKtx2.push({ name: `${tex.getName() || '?'} (${has} ${w0}x${h0})`, slot: u.slot, format: has });
623
+ continue;
624
+ }
625
+ const img = await get(tex);
626
+ let changed = false;
627
+ if (ao?.img) { bakeAO(img, ao.img, S.ao); changed = true; notes.push(`AO baked into ${tex.getName() || 'base map'} (weight ${S.ao})`); }
628
+ const edge = Math.min(spec.edge || Infinity, S.maxTex || Infinity);
629
+ const ktx = spec.format === 'etc1s' || spec.format === 'uastc';
630
+ const scale = Math.min(1, edge / Math.max(img.w, img.h));
631
+ let w = Math.max(1, Math.round(img.w * scale)), h = Math.max(1, Math.round(img.h * scale));
632
+ if (ktx) { w = Math.max(4, Math.round(w / 4) * 4); h = Math.max(4, Math.round(h / 4) * 4); }
633
+ const mime = { jpeg: 'image/jpeg', webp: 'image/webp', 'webp-lossless': 'image/webp', png: 'image/png' }[spec.format] ?? 'image/ktx2';
634
+ if (!changed && w === img.w && h === img.h && mime === tex.getMimeType() && mime !== 'image/ktx2') continue; // no second-generation loss
635
+ if (img.deep && (spec.format === 'png' || spec.format === 'webp-lossless')) { // "lossless" here means 8 bits per channel
636
+ if (!changed && w === img.w && h === img.h) { notes.push(`texture ${tex.getName() || '?'} kept as it is: it is 16-bit, and ${spec.format} would cut it to 8 bits per channel`); continue; }
637
+ notes.push(`warning: texture ${tex.getName() || '?'} is 16-bit; resized, it is written with 8 bits per channel`);
638
+ }
639
+ if (w * 2 <= img.w) {
640
+ const cov = u.refs.some((r) => r.transformed) ? null : coverage(img.w, img.h, u.refs.map((r) => ({ mat: r.mat, texCoord: r.texCoord })));
641
+ if (cov) fillGutters(img, cov);
642
+ }
643
+ const srgb = u.slot === 'base' || u.slot === 'emissive';
644
+ const encoded = await encode(img, w, h, spec, srgb);
645
+ if (!changed && w === img.w && h === img.h && encoded.bytes.length >= tex.getImage().byteLength) {
646
+ notes.push(`texture ${tex.getName() || '?'} kept: the ${spec.format} re-encode was not smaller than the original`);
647
+ continue;
648
+ }
649
+ tex.setImage(new Uint8Array(encoded.bytes)).setMimeType(encoded.mime);
650
+ }
651
+
652
+ if (keptKtx2.length) {
653
+ const [first] = keptKtx2;
654
+ const example = String(P.tex[first.slot]).startsWith(`${first.format}:`) ? P.tex[first.slot] : `${first.format}:1024`;
655
+ notes.push(`KTX2 kept as it is, already in the slot's encoding at no more than its edge (a re-encode is a second lossy generation): `
656
+ + `${keptKtx2.map((k) => k.name).join(', ')}. An explicit spec such as --${first.slot}=${example} re-encodes it for fewer `
657
+ + 'bytes; that output then has to pass the A/B');
658
+ }
659
+ const mimes = new Set(root.listTextures().map((t) => t.getMimeType()));
660
+ for (const [ext, mime] of [[X.KHRTextureBasisu, 'image/ktx2'], [X.EXTTextureWebP, 'image/webp']]) {
661
+ const used = root.listExtensionsUsed().find((e) => e.extensionName === ext.EXTENSION_NAME);
662
+ if (mimes.has(mime)) doc.createExtension(ext).setRequired(true);
663
+ else used?.dispose();
664
+ }
665
+ }
666
+
667
+ function ktxTool(args) {
668
+ if (!ktxState.checked) {
669
+ ktxState.checked = true;
670
+ try {
671
+ const v = execFileSync('ktx', ['--version'], { stdio: 'pipe' }).toString().trim();
672
+ if (!/(?:^|\D)4\.4\./.test(v)) notes.push(`warning: KTX-Software reports "${v}", not the 4.4 this script was tested with (references/tools.md)`);
673
+ } catch { /* the real call below reports a missing tool */ }
674
+ }
675
+ execFileSync('ktx', args, { stdio: 'pipe' });
676
+ }
677
+
678
+ async function decode(tex) {
679
+ let bytes = Buffer.from(tex.getImage());
680
+ if (tex.getMimeType() === 'image/ktx2') {
681
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'optglb-'));
682
+ try {
683
+ fs.writeFileSync(path.join(dir, 'in.ktx2'), bytes);
684
+ ktxTool(['extract', '--transcode', 'rgba8', path.join(dir, 'in.ktx2'), path.join(dir, 'out.png')]);
685
+ bytes = fs.readFileSync(path.join(dir, 'out.png'));
686
+ } catch (e) {
687
+ die(`cannot decode the KTX2 texture ${tex.getName() || ''} (${e.message.split('\n')[0]}). Is KTX-Software installed? See references/tools.md.`);
688
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
689
+ }
690
+ const deep = (await sharp(bytes).metadata()).depth === 'ushort';
691
+ const { data, info } = await sharp(bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
692
+ let minAlpha = 255;
693
+ for (let i = 3; i < data.length && minAlpha; i += 4) minAlpha = Math.min(minAlpha, data[i]);
694
+ // Lossy encodes treat alpha 250+ as opaque; lossless ones keep every value (minAlpha).
695
+ return { px: data, w: info.width, h: info.height, alpha: minAlpha < 250, minAlpha, deep };
696
+ }
697
+
698
+ /** base x (1 - w(1 - ao)) in linear light, AO from the occlusion map's red channel. */
699
+ function bakeAO(img, ao, weight) {
700
+ const aoPx = resizeNearest(ao, img.w, img.h);
701
+ const lin = Array.from({ length: 256 }, (_, c) => { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; });
702
+ const enc = (l) => Math.round(255 * (l <= 0.0031308 ? l * 12.92 : 1.055 * l ** (1 / 2.4) - 0.055));
703
+ for (let i = 0; i < img.w * img.h; i++) {
704
+ const f = 1 - weight * (1 - aoPx[i * 4] / 255);
705
+ for (let c = 0; c < 3; c++) img.px[i * 4 + c] = enc(lin[img.px[i * 4 + c]] * f);
706
+ }
707
+ }
708
+ /** Nearest-neighbour resize of decoded RGBA (the AO map only needs to line up, not to be pretty). */
709
+ function resizeNearest(img, w, h) {
710
+ if (img.w === w && img.h === h) return img.px;
711
+ const res = new Uint8Array(w * h * 4);
712
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
713
+ const s = (Math.min(img.h - 1, Math.floor((y + 0.5) * img.h / h)) * img.w + Math.min(img.w - 1, Math.floor((x + 0.5) * img.w / w))) * 4;
714
+ res.set(img.px.subarray(s, s + 4), (y * w + x) * 4);
715
+ }
716
+ return res;
717
+ }
718
+
719
+ /** Texels any triangle covers (1 px conservative), or null when UVs tile and gutters do not exist. */
720
+ function coverage(w, h, refs) {
721
+ const mask = new Uint8Array(w * h);
722
+ const el = [0, 0];
723
+ for (const { mat, texCoord } of refs) {
724
+ for (const prim of root.listMeshes().flatMap((m) => m.listPrimitives()).filter((p) => p.getMaterial() === mat)) {
725
+ const uv = prim.getAttribute(`TEXCOORD_${texCoord}`), I = prim.getIndices();
726
+ if (!uv || !I) return null;
727
+ const U = new Float32Array(uv.getCount() * 2);
728
+ for (let i = 0; i < uv.getCount(); i++) { uv.getElement(i, el); U[i * 2] = el[0] * w; U[i * 2 + 1] = el[1] * h; }
729
+ if (U.some((v, i) => v < -0.01 * (i % 2 ? h : w) || v > 1.01 * (i % 2 ? h : w))) return null;
730
+ const ia = I.getArray();
731
+ for (let t = 0; t < ia.length; t += 3) {
732
+ const [ax, ay, bx, by, cx, cy] = [U[ia[t] * 2], U[ia[t] * 2 + 1], U[ia[t + 1] * 2], U[ia[t + 1] * 2 + 1], U[ia[t + 2] * 2], U[ia[t + 2] * 2 + 1]];
733
+ const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); if (!area) continue;
734
+ const s = Math.sign(area);
735
+ const edges = [[ax, ay, bx, by], [bx, by, cx, cy], [cx, cy, ax, ay]].map(([x0, y0, x1, y1]) => [x0, y0, x1 - x0, y1 - y0, Math.hypot(x1 - x0, y1 - y0) || 1]);
736
+ for (let y = Math.max(0, Math.floor(Math.min(ay, by, cy) - 1)); y <= Math.min(h - 1, Math.ceil(Math.max(ay, by, cy) + 1)); y++) {
737
+ for (let x = Math.max(0, Math.floor(Math.min(ax, bx, cx) - 1)); x <= Math.min(w - 1, Math.ceil(Math.max(ax, bx, cx) + 1)); x++) {
738
+ if (edges.every(([x0, y0, dx, dy, len]) => s * (dx * (y + 0.5 - y0) - dy * (x + 0.5 - x0)) / len >= -1)) mask[y * w + x] = 1;
739
+ }
740
+ }
741
+ }
742
+ }
743
+ }
744
+ return mask;
745
+ }
746
+
747
+ /** Push-pull: every uncovered texel takes the average of the covered ones nearest it, so seams do not bleed when downsized. */
748
+ function fillGutters(img, mask) {
749
+ const pull = (px, m, w, h) => {
750
+ if (w <= 1 && h <= 1) return;
751
+ const W = Math.ceil(w / 2), H = Math.ceil(h / 2), cp = new Float32Array(W * H * 4), cm = new Float32Array(W * H);
752
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
753
+ const i = y * w + x; if (!m[i]) continue;
754
+ const j = (y >> 1) * W + (x >> 1); cm[j]++;
755
+ for (let c = 0; c < 4; c++) cp[j * 4 + c] += px[i * 4 + c];
756
+ }
757
+ let holes = false;
758
+ for (let j = 0; j < W * H; j++) if (cm[j]) { for (let c = 0; c < 4; c++) cp[j * 4 + c] /= cm[j]; cm[j] = 1; } else holes = true;
759
+ if (holes) pull(cp, cm, W, H);
760
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
761
+ const i = y * w + x; if (m[i]) continue;
762
+ const j = (y >> 1) * W + (x >> 1);
763
+ for (let c = 0; c < 4; c++) px[i * 4 + c] = cp[j * 4 + c];
764
+ }
765
+ };
766
+ if (!mask.some(Boolean)) return;
767
+ const f = Float32Array.from(img.px);
768
+ pull(f, mask, img.w, img.h);
769
+ for (let i = 0; i < f.length; i++) img.px[i] = Math.round(f[i]);
770
+ }
771
+
772
+ async function encode(img, w, h, spec, srgb) {
773
+ const base = sharp(img.px, { raw: { width: img.w, height: img.h, channels: 4 } }).resize(w, h, { kernel: 'lanczos3', fit: 'fill' });
774
+ const lossless = spec.format === 'png' || spec.format === 'webp-lossless';
775
+ const s = (lossless ? img.minAlpha < 255 : img.alpha) ? base : base.removeAlpha();
776
+ const q = spec.quality;
777
+ if (spec.format === 'jpeg' && !img.alpha) return { bytes: await s.jpeg({ quality: q ?? 90, chromaSubsampling: '4:4:4', mozjpeg: true }).toBuffer(), mime: 'image/jpeg' };
778
+ if (spec.format === 'jpeg') notes.push('a texture with transparency was written as WebP: JPEG has no alpha');
779
+ if (spec.format === 'webp' || spec.format === 'jpeg') return { bytes: await s.webp({ quality: q ?? 90, effort: 6, smartSubsample: false, alphaQuality: 100 }).toBuffer(), mime: 'image/webp' };
780
+ // exact: libwebp otherwise rewrites the colour under fully transparent texels, and code may read it.
781
+ if (spec.format === 'webp-lossless') return { bytes: await s.webp({ lossless: true, exact: true, effort: 6 }).toBuffer(), mime: 'image/webp' };
782
+ if (spec.format === 'png') return { bytes: await s.png({ compressionLevel: 9, effort: 10 }).toBuffer(), mime: 'image/png' };
783
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'optglb-'));
784
+ try {
785
+ fs.writeFileSync(path.join(dir, 'in.png'), await s.png({ compressionLevel: 1 }).toBuffer());
786
+ const args = ['create', '--generate-mipmap', '--mipmap-filter', 'lanczos4', '--threads', '4',
787
+ '--format', `${img.alpha ? 'R8G8B8A8' : 'R8G8B8'}_${srgb ? 'SRGB' : 'UNORM'}`,
788
+ '--assign-tf', srgb ? 'srgb' : 'linear', '--assign-primaries', srgb ? 'bt709' : 'none'];
789
+ if (spec.format === 'etc1s') args.push('--encode', 'basis-lz', '--clevel', '4', '--qlevel', String(q ?? 255));
790
+ else args.push('--encode', 'uastc', '--uastc-quality', '2', '--zstd', '18', ...((q ?? 2) > 0 ? ['--uastc-rdo', '--uastc-rdo-l', String(q ?? 2)] : []));
791
+ ktxTool([...args, path.join(dir, 'in.png'), path.join(dir, 'out.ktx2')]);
792
+ return { bytes: fs.readFileSync(path.join(dir, 'out.ktx2')), mime: 'image/ktx2' };
793
+ } catch (e) {
794
+ die(`ktx failed (${e.message.split('\n')[0]}). Is KTX-Software on PATH? See references/tools.md.`);
795
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
796
+ }
797
+
798
+ // ---------------------------------------------------------------------------------------------- checks + report
799
+ function nodeNames(d) { return d.getRoot().listNodes().map((n) => n.getName()).filter(Boolean).sort(); }
800
+
801
+ function checkToolVersions() {
802
+ const off = Object.entries(TESTED).map(([pkg, want]) => {
803
+ try { return [pkg, want, JSON.parse(fs.readFileSync(path.join(G, pkg, 'package.json'), 'utf8')).version]; } catch { return [pkg, want, 'missing']; }
804
+ }).filter(([, want, got]) => got !== want);
805
+ if (off.length) {
806
+ notes.push(`warning: the shared tools differ from the versions this script was tested with (${off.map(([p, w, g]) => `${p} ${g}, tested ${w}`).join('; ')}). `
807
+ + 'Their defaults can change between versions: reinstall them as references/tools.md pins them');
808
+ }
809
+ }
810
+
811
+ function checkOutput(o, glb) {
812
+ const c = [];
813
+ const got = nodeNames(o);
814
+ const missing = namesIn.filter((n, i, a) => got.filter((g) => g === n).length < a.filter((x) => x === n).length);
815
+ c.push({ name: 'node names', fail: missing.length > 0, detail: missing.length ? `missing: ${[...new Set(missing)].slice(0, 5).join(', ')}` : `${namesIn.length} kept` });
816
+ c.push({ name: 'texture slots', fail: lostSlots.length > 0, detail: lostSlots.length ? `lost: ${lostSlots.slice(0, 5).join(', ')}` : `${liveSlots.length} kept` });
817
+ if (P.pairs) {
818
+ c.push({ name: 'paired sizes', fail: unpaired.length > 0,
819
+ detail: unpaired.length ? `${unpaired.slice(0, 3).join('; ')} (code pairing a base map with its mask falls back to the stock look)` : 'same-size textures still match' });
820
+ }
821
+ // SEPARATE layout: no buffer view holds more than one vertex attribute.
822
+ const json = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
823
+ const perView = new Map();
824
+ for (const m of json.meshes ?? []) for (const p of m.primitives) for (const a of Object.values(p.attributes)) {
825
+ const v = json.accessors[a].bufferView; if (v === undefined) continue;
826
+ perView.set(v, (perView.get(v) ?? new Set()).add(a));
827
+ }
828
+ const inter = [...perView.values()].filter((s) => s.size > 1).length;
829
+ c.push({ name: 'vertex layout', fail: inter > 0, detail: inter ? `${inter} interleaved buffer views (WebGPU crash)` : 'separate' });
830
+ const bad = [];
831
+ for (const k of snap) {
832
+ const n = o.getRoot().listNodes().find((x) => x.getName() === k.n.getName());
833
+ const same = n && [...n.getTranslation(), ...n.getRotation(), ...n.getScale()].every((v, i) => Math.abs(v - [...k.t, ...k.r, ...k.s][i]) < 1e-6);
834
+ const float = n?.getMesh()?.listPrimitives().every((p) => p.getAttribute('POSITION').getComponentType() === 5126);
835
+ if (!same || !float) bad.push(k.n.getName());
836
+ }
837
+ if (snap.length) c.push({ name: 'kept-exact nodes', fail: bad.length > 0, detail: bad.length ? `moved: ${bad.join(', ')}` : `${snap.length} unchanged` });
838
+ const skinnedQuantized = o.getRoot().listMeshes().flatMap((m) => m.listPrimitives()).some((p) => p.getAttribute('JOINTS_0') && p.getAttribute('POSITION').getComponentType() !== 5126);
839
+ if (deforming) c.push({ name: 'skinned positions', fail: skinnedQuantized, detail: skinnedQuantized ? 'quantized: bind pose moves' : 'float32' });
840
+ const [b0, b1] = [getBounds(before.getRoot().getDefaultScene() ?? before.getRoot().listScenes()[0]), getBounds(o.getRoot().getDefaultScene() ?? o.getRoot().listScenes()[0])];
841
+ const ext = Math.max(...[0, 1, 2].map((k) => b0.max[k] - b0.min[k])) || 1;
842
+ const drift = Math.max(...[0, 1, 2].flatMap((k) => [Math.abs(b1.min[k] - b0.min[k]), Math.abs(b1.max[k] - b0.max[k])])) / ext;
843
+ c.push({ name: 'bounds', fail: drift > 0.005, detail: `${(drift * 100).toFixed(3)}% of size${drift > 0.005 ? ' (the engine rescales and re-centres on these)' : ''}` });
844
+ const smaller = glb.length < statsBefore.bytes;
845
+ c.push({ name: 'smaller', fail: !smaller, detail: smaller ? `${mb(statsBefore.bytes - glb.length)} saved` : 'the output is not smaller than the input: keep the original' });
846
+ return c;
847
+ }
848
+
849
+ async function validate(glb, original) {
850
+ let validator;
851
+ try { validator = createRequire(import.meta.url)(path.join(G, 'gltf-validator')); } catch { return { name: 'Khronos validator', detail: 'not installed, skipped' }; }
852
+ // This validator build predates KTX2 images: it flags their mime type and format. Not a defect.
853
+ const ktx = (m) => /^\/images\/\d+/.test(m.pointer ?? '') && ['VALUE_NOT_IN_LIST', 'IMAGE_UNRECOGNIZED_FORMAT'].includes(m.code);
854
+ const issues = async (b, severity) => (await validator.validateBytes(new Uint8Array(b), { maxIssues: 200 })).issues.messages
855
+ .filter((m) => m.severity === severity && !ktx(m));
856
+ const [errs, warns, had] = [await issues(glb, 0), await issues(glb, 1), new Set((await issues(original, 0)).map((m) => m.code))];
857
+ const fresh = errs.filter((m) => !had.has(m.code)); // an error the original already had is not this run's doing
858
+ return { name: 'Khronos validator', fail: fresh.length > 0,
859
+ detail: `${errs.length} errors (${fresh.length} new), ${warns.length} warnings${[...fresh, ...warns].slice(0, 3).map((m) => `; ${m.code} ${m.pointer ?? ''}`).join('')}` };
860
+ }
861
+
862
+ function stats(d, fileBytes) {
863
+ const r = d.getRoot();
864
+ const prims = r.listMeshes().flatMap((m) => m.listPrimitives());
865
+ const acc = new Set(prims.flatMap((p) => [...p.listAttributes(), p.getIndices()].filter(Boolean)));
866
+ const uses = textureUses(d);
867
+ const textures = r.listTextures().map((t) => {
868
+ const [w, h] = t.getSize() ?? [0, 0];
869
+ const ktx = t.getMimeType() === 'image/ktx2';
870
+ return { name: t.getName() || t.getURI() || '?', slot: uses.get(t)?.slot ?? '-', w, h, mime: t.getMimeType().replace('image/', ''),
871
+ bytes: t.getImage()?.byteLength ?? 0, gpu: Math.round(w * h * (ktx ? 1 : 4) * 4 / 3) };
872
+ });
873
+ return {
874
+ bytes: fileBytes,
875
+ triangles: prims.reduce((n, p) => n + (p.getIndices()?.getCount() ?? p.getAttribute('POSITION').getCount()) / 3, 0),
876
+ vertices: [...new Set(prims.map((p) => p.getAttribute('POSITION')))].reduce((n, a) => n + a.getCount(), 0),
877
+ nodes: r.listNodes().length, skins: r.listSkins().length, animations: r.listAnimations().length,
878
+ gpuGeometry: [...acc].reduce((n, a) => n + a.getByteLength(), 0),
879
+ gpuTextures: textures.reduce((n, t) => n + t.gpu, 0),
880
+ textures,
881
+ extensions: r.listExtensionsUsed().map((e) => e.extensionName),
882
+ };
883
+ }
884
+
885
+ function printStats(file, s) {
886
+ console.log(`${file}: ${mb(s.bytes)}, ${s.triangles.toLocaleString()} triangles, ${s.vertices.toLocaleString()} vertices, ${s.nodes} nodes, ${s.skins} skins, ${s.animations} clips`);
887
+ console.log(` GPU estimate: textures ${mb(s.gpuTextures)}, geometry ${mb(s.gpuGeometry)}`);
888
+ for (const t of s.textures) console.log(` texture ${t.name.slice(0, 28).padEnd(28)} ${t.slot.padEnd(8)} ${`${t.w}x${t.h}`.padEnd(9)} ${t.mime.padEnd(5)} ${mb(t.bytes).padStart(10)} GPU ${mb(t.gpu)}`);
889
+ console.log(` extensions: ${s.extensions.join(', ') || 'none'}`);
890
+ if (s.extensions.includes('EXT_meshopt_compression')) console.log(' already EXT_meshopt_compression: probably an optimized variant. Optimize its original instead');
891
+ }
892
+ function printReport() {
893
+ const pct = (a, b) => (a ? `${b <= a ? '-' : '+'}${Math.abs(100 - (b / a) * 100).toFixed(0)}%` : '');
894
+ const row = (label, a, b, f = (v) => v.toLocaleString()) => console.log(` ${label.padEnd(20)} ${f(a).padStart(12)} -> ${f(b).padStart(12)} ${pct(a, b)}`);
895
+ console.log(`${src} -> ${dst} preset ${flag('preset')}`);
896
+ row('file (transfer)', statsBefore.bytes, statsAfter.bytes, mb);
897
+ row('triangles', statsBefore.triangles, statsAfter.triangles);
898
+ row('GPU textures (est)', statsBefore.gpuTextures, statsAfter.gpuTextures, mb);
899
+ row('GPU geometry (est)', statsBefore.gpuGeometry, statsAfter.gpuGeometry, mb);
900
+ console.log(' textures after:');
901
+ for (const t of statsAfter.textures) console.log(` ${t.name.slice(0, 28).padEnd(28)} ${t.slot.padEnd(8)} ${`${t.w}x${t.h}`.padEnd(9)} ${t.mime.padEnd(5)} ${mb(t.bytes).padStart(10)}`);
902
+ if (statsAfter.extensions.includes('KHR_texture_basisu') && !statsBefore.extensions.includes('KHR_texture_basisu')) {
903
+ notes.push('first KTX2 texture in this file: if no other asset in the game uses KTX2, players also download the Basis transcoder (~0.25 MB). '
904
+ + 'On WebGPU, an engine without the KTX2 wait fails a KTX2 file that loads during boot (SKILL.md step 5)');
905
+ }
906
+ if (S.meshopt) notes.push('EXT_meshopt_compression: needs an engine that attaches the meshopt decoder, or a game-side one (SKILL.md step 5)');
907
+ for (const n of notes) console.log(` note: ${n}`);
908
+ for (const c of checks) console.log(` ${c.fail ? 'FAIL' : 'ok '} ${c.name}: ${c.detail}`);
909
+ }
910
+
911
+ /** Download size of a URL from a HEAD request (the transfer size when the CDN compresses it), or 0. */
912
+ async function headSize(url) {
913
+ try {
914
+ const r = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000) });
915
+ const n = Number(r.headers.get('content-length'));
916
+ return r.ok && n > 0 ? n : 0;
917
+ } catch { return 0; }
918
+ }
919
+
920
+ async function audit(worldPath) {
921
+ if (!fs.existsSync(worldPath)) die(`no ${worldPath}`);
922
+ const world = JSON.parse(fs.readFileSync(worldPath, 'utf8'));
923
+ const elsewhere = JSON.stringify({ ...world, assets: undefined });
924
+ if (!fs.existsSync('src')) console.log('warning: no src/ here, so the "used" column cannot see code. Run the audit from the project root.');
925
+ const source = sourceFiles('src').map(([, text]) => text).join('\n');
926
+ const quoted = (s) => ['\'', '"', '`'].some((q) => source.includes(`${q}${s}${q}`));
927
+ const rows = (world.assets ?? []).map((a) => {
928
+ const url = String(a.url ?? ''), file = url.split('?')[0].split('/').pop() ?? '', name = String(a.name ?? '');
929
+ const inCode = source.includes(a.id) || (url !== '' && source.includes(url)) || (file.length >= 8 && source.includes(file)) || (name.length >= 3 && quoted(name));
930
+ return { id: a.id, name, type: a.type ?? '?', size: Number(a.size) || 0, headed: false, url, file: file.slice(-44),
931
+ used: elsewhere.includes(a.id) || (url !== '' && elsewhere.includes(url)) ? 'world' : inCode ? 'code' : '-',
932
+ variant: VARIANT_NAME.test(name) || VARIANT_NAME.test(file.replace(/\.[a-z0-9]+$/i, '')) };
933
+ });
934
+ const unsized = rows.filter((r) => !r.size && /^https?:\/\//.test(r.url));
935
+ for (let i = 0; i < unsized.length; i += 6) {
936
+ await Promise.all(unsized.slice(i, i + 6).map(async (r) => { r.size = await headSize(r.url); r.headed = r.size > 0; }));
937
+ }
938
+ rows.sort((a, b) => (Number(!a.size) - Number(!b.size)) * -1 || b.size - a.size); // unknown sizes first: those need a look
939
+ const total = rows.reduce((n, r) => n + r.size, 0);
940
+ let running = 0;
941
+ console.log(' size cum% used type name id file');
942
+ for (const r of rows) {
943
+ running += r.size;
944
+ const size = r.size ? `${r.headed ? '~' : ''}${mb(r.size)}` : 'unknown';
945
+ const cum = r.size ? `${((running / (total || 1)) * 100).toFixed(0)}%` : '?';
946
+ console.log(`${size.padStart(11)} ${cum.padStart(5)} ${r.used.padEnd(6)} ${r.type.padEnd(9)} ${r.name.slice(0, 30).padEnd(30)} ${r.id} ${r.file}${r.variant ? ' (variant?)' : ''}`);
947
+ }
948
+ const unknown = rows.filter((r) => !r.size).length, headed = rows.filter((r) => r.headed).length;
949
+ console.log(`${mb(total).padStart(11)} over ${rows.length} rows${headed ? ` (${headed} sized by a HEAD request, marked ~)` : ''}${unknown ? `; ${unknown} of unknown size, listed first: check them` : ''}.`);
950
+ console.log('used: world = referenced elsewhere in world.json; code = its id, url, file name or quoted name is in src/; - = neither.');
951
+ console.log('Neither "-" nor "world"/"code" proves what the game fetches: confirm the real download in the network panel.');
952
+ if (rows.some((r) => r.variant)) console.log('(variant?) = the name looks like an optimized copy: optimize the original row, not the copy.');
953
+ }