@looop-games/cli 0.1.19 → 0.1.20

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 CHANGED
@@ -14,6 +14,28 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.20] - 2026-07-29
18
+
19
+ ### Added
20
+
21
+ - **Generator scripts: `looop model bake` can run your code at bake time.**
22
+ Name a game-committed script in `<model>.bake.json`
23
+ (`{ "generate": "creature-gen.mjs" }`) and the bake hands it the parsed
24
+ model. Export `clips(model)` to bake a procedural pose function into
25
+ additive clips (sampled into delta tracks the engine's layer slots play —
26
+ see the engine changelog), and/or `zones(model)` to label triangles your
27
+ own way when bone names can't describe the mesh (one byte per triangle +
28
+ a name table). The script must be deterministic — the bake refuses
29
+ `Math.random()`-shaped output — and its text joins the staleness hash, so
30
+ editing the maths re-bakes automatically, exactly like editing the GLB.
31
+ The broadphase radius grows to contain every generated pose at weight 1.
32
+
33
+ ### Changed
34
+
35
+ - Baked artifacts re-bake once after this update (the artifact format version
36
+ advanced to carry generated clips). No action needed — the next
37
+ `looop dev`/`test`/`publish` does it.
38
+
17
39
  ## [0.1.19] - 2026-07-22
18
40
 
19
41
  ### Added
@@ -28,7 +28,8 @@
28
28
  // ── Optional bake config: <model>.bake.json next to the GLB ──────────────────
29
29
  // {
30
30
  // "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
31
- // "clips": ["fly", "idle"] // subset to bake; default = all
31
+ // "clips": ["fly", "idle"], // subset to bake; default = all
32
+ // "generate": "drifter-gen.mjs" // generator script, relative to the GLB
32
33
  // }
33
34
  // Zones use the dominant-bone rule (a triangle belongs to whichever bone most
34
35
  // influences its vertices — the same rule the big engines use to size their
@@ -36,6 +37,13 @@
36
37
  // names, `*` is a wildcard. Without config the mesh gets NO zones: hits still land
37
38
  // on the exact surface, damage is uniform, and `hitRay` reports `zone: null`.
38
39
  //
40
+ // `generate` names a game-committed script that runs AT BAKE TIME with the
41
+ // parsed model and returns data the engine carries: additive clips baked from a
42
+ // procedural pose function (`export clips`) and/or per-triangle zone bytes for
43
+ // meshes bone names can't describe (`export zones`). See the generator-script
44
+ // section below for the exact contract. The script's text participates in the
45
+ // staleness hash, so editing it re-bakes exactly like editing the GLB.
46
+ //
39
47
  // ── What a bake rejects, loudly ──────────────────────────────────────────────
40
48
  // - scale tracks in a clip (the runtime rig is uniform-scale; a scaling clip
41
49
  // cannot be reproduced, so it fails the bake instead of silently drifting)
@@ -49,11 +57,14 @@
49
57
 
50
58
  import { createHash } from 'node:crypto';
51
59
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
52
- import { join, resolve, basename } from 'node:path';
60
+ import { join, resolve, basename, dirname } from 'node:path';
61
+ import { pathToFileURL } from 'node:url';
53
62
 
54
63
  // Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
55
64
  // meaning — it participates in the staleness hash, so old artifacts re-bake.
56
- export const BAKE_VERSION = 1;
65
+ // v2: the staleness hash gained the generator-script component, and skeletons
66
+ // may carry generated additive clips.
67
+ export const BAKE_VERSION = 2;
57
68
 
58
69
  // ── pure helpers (unit-tested without a browser) ─────────────────────────────
59
70
 
@@ -112,13 +123,16 @@ export function sha256(buf) {
112
123
  return createHash('sha256').update(buf).digest('hex');
113
124
  }
114
125
 
115
- // The staleness key an artifact records: GLB bytes + bake config + bake version.
116
- // If any of the three changes, the artifact is stale and auto-bake re-runs.
117
- export function sourceHash(glbBytes, configText) {
126
+ // The staleness key an artifact records: GLB bytes + bake config + generator
127
+ // script + bake version. If any of the four changes, the artifact is stale and
128
+ // auto-bake re-runs editing the generator's wing maths re-bakes exactly like
129
+ // editing the GLB does.
130
+ export function sourceHash(glbBytes, configText, generatorText = '') {
118
131
  return sha256(Buffer.concat([
119
132
  Buffer.from(`bake${BAKE_VERSION}!`),
120
133
  Buffer.from(sha256(glbBytes)),
121
134
  Buffer.from(sha256(Buffer.from(configText ?? ''))),
135
+ Buffer.from(sha256(Buffer.from(generatorText ?? ''))),
122
136
  ]));
123
137
  }
124
138
 
@@ -127,12 +141,28 @@ export function artifactPaths(glbPath) {
127
141
  return { skeleton: `${stem}.skeleton.json`, mesh: `${stem}.mesh.json`, config: `${stem}.bake.json` };
128
142
  }
129
143
 
130
- // Is the artifact pair current for this GLB + config? (Missing stale.)
144
+ // The generator script's current text, resolved from the bake config '' when
145
+ // the config names none. A named-but-missing script reads as null so staleness
146
+ // can answer "stale" and the bake itself can fail with the real error.
147
+ function generatorText(glbPath, configText) {
148
+ if (!configText) return '';
149
+ let gen;
150
+ try { gen = JSON.parse(configText)?.generate; } catch { return ''; } // bad JSON fails in bakeModel with the real message
151
+ if (!gen) return '';
152
+ const p = resolve(dirname(glbPath), gen);
153
+ if (!existsSync(p)) return null;
154
+ return readFileSync(p, 'utf8');
155
+ }
156
+
157
+ // Is the artifact pair current for this GLB + config + generator? (Missing → stale.)
131
158
  export function isStale(glbPath) {
132
159
  const { skeleton, mesh, config } = artifactPaths(glbPath);
133
160
  if (!existsSync(skeleton) || !existsSync(mesh)) return true;
134
161
  try {
135
- const want = sourceHash(readFileSync(glbPath), existsSync(config) ? readFileSync(config, 'utf8') : '');
162
+ const configText = existsSync(config) ? readFileSync(config, 'utf8') : '';
163
+ const genText = generatorText(glbPath, configText);
164
+ if (genText === null) return true; // config names a script that is gone
165
+ const want = sourceHash(readFileSync(glbPath), configText, genText);
136
166
  const have = JSON.parse(readFileSync(skeleton, 'utf8'))?._source?.hash;
137
167
  return have !== want;
138
168
  } catch {
@@ -140,6 +170,134 @@ export function isStale(glbPath) {
140
170
  }
141
171
  }
142
172
 
173
+ // ── generator scripts: game code runs at bake time, the engine ships data ────
174
+ // bake.json may name a game-committed script: { "generate": "drifter-gen.mjs" }
175
+ // (path relative to the GLB). The script receives the parsed model and returns
176
+ // DATA — additive clip samples and/or per-triangle zone bytes. The engine
177
+ // validates and carries what it returns; it learns no geometric language and
178
+ // runs no game code after the bake. Two exports, both optional, each either the
179
+ // value or a function of the model:
180
+ //
181
+ // export function clips(model) {
182
+ // return { beat: { duration: 1, additive: true, keys: 64, sample: (t) => ({
183
+ // WingL: { rot: [x, y, z, w] }, // delta from rest, unit quaternion
184
+ // Chest: { pos: [x, y, z] }, // delta from rest, model units
185
+ // }) } };
186
+ // }
187
+ // export function zones(model) {
188
+ // return { names: ['body', 'wing'], tri: bytes }; // one byte per triangle
189
+ // }
190
+ //
191
+ // `model` = { bones, clips, mesh: { vertexCount, triangleCount, influences,
192
+ // position, skinIndex, skinWeight, index } } — everything the bake read from
193
+ // the GLB, typed arrays decoded.
194
+ //
195
+ // sample(t) must be DETERMINISTIC (no Math.random(), no Date.now()): the tracks
196
+ // it produces are replicated pose truth, and a re-bake must reproduce them
197
+ // byte-identically or the staleness hash lies.
198
+
199
+ // Sample each generated clip definition into baked additive tracks.
200
+ export function buildGeneratedClips(defs, boneNames, existingClipNames = []) {
201
+ const known = new Set(boneNames);
202
+ const existing = new Set(existingClipNames);
203
+ const clips = {};
204
+ for (const [name, def] of Object.entries(defs ?? {})) {
205
+ if (existing.has(name)) {
206
+ throw new Error(`generated clip "${name}" collides with a clip already in the GLB — rename one of them`);
207
+ }
208
+ if (def?.additive !== true) {
209
+ throw new Error(`generated clip "${name}" must declare additive: true — generated clips are deltas layered over the base pose; base clips come from the GLB`);
210
+ }
211
+ if (typeof def.sample !== 'function') {
212
+ throw new Error(`generated clip "${name}": "sample" must be a function of time`);
213
+ }
214
+ const duration = def.duration ?? 0;
215
+ // Key times are INCLUSIVE of the end (0..duration), so a looping clip
216
+ // interpolates across the seam instead of stepping at the wrap.
217
+ const keys = duration > 0 ? Math.max(2, Math.round(def.keys ?? 64)) : 0;
218
+ const times = duration > 0
219
+ ? Array.from({ length: keys + 1 }, (_, k) => (k / keys) * duration)
220
+ : [0];
221
+
222
+ // Determinism probe: the cheapest check that catches the whole class of
223
+ // Math.random()/Date.now() mistakes before they become flickering re-bakes.
224
+ if (JSON.stringify(def.sample(times[0])) !== JSON.stringify(def.sample(times[0]))) {
225
+ throw new Error(`generated clip "${name}": sample(t) is not deterministic — identical t must return identical output (no Math.random(), no Date.now()); these tracks are replicated pose truth and a re-bake must reproduce them exactly`);
226
+ }
227
+
228
+ let shape = null; // bone -> { rot: bool, pos: bool }, fixed by the first sample
229
+ const acc = new Map(); // bone -> { times, rot[], pos[] }
230
+ for (const t of times) {
231
+ const frame = def.sample(t) ?? {};
232
+ const frameBones = Object.keys(frame);
233
+ if (shape === null) {
234
+ shape = new Map();
235
+ for (const bn of frameBones) {
236
+ if (!known.has(bn)) {
237
+ throw new Error(`generated clip "${name}": sample() returned bone "${bn}" — the skeleton has no such bone. Bones are: ${boneNames.join(', ')}`);
238
+ }
239
+ shape.set(bn, { rot: !!frame[bn]?.rot, pos: !!frame[bn]?.pos });
240
+ }
241
+ if (!shape.size) throw new Error(`generated clip "${name}": sample() returned no bones`);
242
+ } else if (frameBones.length !== shape.size || !frameBones.every((bn) => shape.has(bn))) {
243
+ throw new Error(`generated clip "${name}": sample() must return the same bones (with the same rot/pos properties) at every time — t=${t} differs from t=${times[0]}`);
244
+ }
245
+ for (const [bn, spec] of shape) {
246
+ const v = frame[bn];
247
+ if (!!v?.rot !== spec.rot || !!v?.pos !== spec.pos) {
248
+ throw new Error(`generated clip "${name}": sample() must return the same bones (with the same rot/pos properties) at every time — bone "${bn}" at t=${t} differs`);
249
+ }
250
+ let entry = acc.get(bn);
251
+ if (!entry) { entry = { times: [], rot: spec.rot ? [] : null, pos: spec.pos ? [] : null }; acc.set(bn, entry); }
252
+ entry.times.push(t);
253
+ if (spec.rot) {
254
+ const q = v.rot;
255
+ const L = Math.hypot(q[0], q[1], q[2], q[3]);
256
+ if (!(L > 0) || Math.abs(L - 1) > 1e-3) {
257
+ throw new Error(`generated clip "${name}", bone "${bn}" at t=${t}: rotation is not unit length (${L.toFixed(4)}) — normalize your quaternions`);
258
+ }
259
+ entry.rot.push(q[0] / L, q[1] / L, q[2] / L, q[3] / L);
260
+ }
261
+ if (spec.pos) entry.pos.push(v.pos[0], v.pos[1], v.pos[2]);
262
+ }
263
+ }
264
+ clips[name] = {
265
+ duration,
266
+ additive: true,
267
+ tracks: [...acc.entries()].map(([bone, e]) => ({
268
+ bone, times: e.times,
269
+ ...(e.rot ? { rot: e.rot } : {}),
270
+ ...(e.pos ? { pos: e.pos } : {}),
271
+ })),
272
+ };
273
+ }
274
+ return clips;
275
+ }
276
+
277
+ // Validate a zone generator's return: bring-your-own-bytes, checked hard.
278
+ export function validateGeneratedZones(z, triangleCount) {
279
+ if (!z || !Array.isArray(z.names) || z.names.length === 0 || !z.names.every((n) => typeof n === 'string')) {
280
+ throw new Error('zone generator: "names" must be a non-empty array of zone name strings (index 0 is the default zone)');
281
+ }
282
+ if (z.names.length > 256) {
283
+ throw new Error(`zone generator: at most 256 zone names (got ${z.names.length}) — zones are stored one byte per triangle`);
284
+ }
285
+ const tri = z.tri;
286
+ const len = tri?.length ?? -1;
287
+ if (len !== triangleCount) {
288
+ throw new Error(`zone generator: "tri" must carry one byte per triangle — the mesh has ${triangleCount} triangles, got ${len}`);
289
+ }
290
+ const out = new Uint8Array(triangleCount);
291
+ for (let i = 0; i < triangleCount; i++) {
292
+ const v = tri[i];
293
+ if (!Number.isInteger(v) || v < 0 || v >= z.names.length) {
294
+ throw new Error(`zone generator: zone byte ${v} at triangle ${i} is out of range — must be an integer 0..${z.names.length - 1}`);
295
+ }
296
+ out[i] = v;
297
+ }
298
+ return { names: [...z.names], tri: out };
299
+ }
300
+
143
301
  // ── the in-page bake ─────────────────────────────────────────────────────────
144
302
  // Runs inside the browser with three + GLTFLoader importable. Everything it
145
303
  // returns is plain JSON (typed arrays as b64).
@@ -404,19 +562,19 @@ async function bakeInPage(page, cfg) {
404
562
  // models lives there, so the loader that bakes and the loader that draws move
405
563
  // together, on engine releases. The bake never touches the network.
406
564
  const VENDOR_REL = 'shared/ui/room/vendor/three-gltf.js';
407
- function threeBundle(dir) {
565
+ function engineFile(dir, rel) {
408
566
  const candidates = [
409
567
  // A standalone game: the engine installed under node_modules.
410
- join(dir, 'node_modules/@looop-games/engine', VENDOR_REL),
568
+ join(dir, 'node_modules/@looop-games/engine', rel),
411
569
  // This CLI running from inside the engine repo itself.
412
- join(import.meta.dirname, '../../..', VENDOR_REL),
570
+ join(import.meta.dirname, '../../..', rel),
413
571
  ];
414
572
  for (const c of candidates) if (existsSync(c)) return c;
415
573
  throw new Error(
416
- 'the installed engine does not include the model loader '
417
- + `(${VENDOR_REL}) — run \`npx looop update\` to get a newer engine`,
574
+ `the installed engine does not include ${rel} run \`npx looop update\` to get a newer engine`,
418
575
  );
419
576
  }
577
+ const threeBundle = (dir) => engineFile(dir, VENDOR_REL);
420
578
 
421
579
  // ── the driver ───────────────────────────────────────────────────────────────
422
580
 
@@ -478,21 +636,117 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
478
636
  if (w.badSums > 0) problems.push(`${w.badSums} vertices whose weights do not sum to ~1`);
479
637
  if (problems.length) throw new Error(`BAKE INVALID:\n ${problems.join('\n ')}`);
480
638
 
481
- const source = { hash: sourceHash(glbBytes, configText), glb: basename(abs), bakeVersion: BAKE_VERSION };
639
+ const skeletonJson = {
640
+ root: { t: baked.root.t, r: baked.root.r, s: rootS },
641
+ bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
642
+ clips: baked.clips,
643
+ };
644
+
645
+ // ── the generator script, if the config names one ────────────────────────
646
+ let genText = '';
647
+ let generatedNames = [];
648
+ let genZones = null;
649
+ if (cfg.generate) {
650
+ const scriptPath = resolve(dirname(abs), cfg.generate);
651
+ if (!existsSync(scriptPath)) {
652
+ throw new Error(`${basename(paths.config)} names generate: "${cfg.generate}" but ${scriptPath} does not exist`);
653
+ }
654
+ genText = readFileSync(scriptPath, 'utf8');
655
+ // The hash query re-imports a changed script within one process (node
656
+ // caches module URLs forever; ensureBaked may bake twice in one run).
657
+ const mod = await import(`${pathToFileURL(scriptPath).href}?v=${sha256(Buffer.from(genText)).slice(0, 12)}`);
658
+ if (!mod.clips && !mod.zones) {
659
+ throw new Error(`generator ${cfg.generate} exports neither clips nor zones — nothing to generate`);
660
+ }
661
+ const fromB64 = (Ctor, entry) => {
662
+ const buf = Buffer.from(entry.b64, 'base64');
663
+ return new Ctor(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
664
+ };
665
+ const model = {
666
+ bones: skeletonJson.bones,
667
+ clips: baked.clips,
668
+ mesh: {
669
+ vertexCount: baked.mesh.vertexCount,
670
+ triangleCount: baked.mesh.triangleCount,
671
+ influences: baked.mesh.influences,
672
+ position: fromB64(Float32Array, baked.mesh.position),
673
+ skinIndex: fromB64(Uint16Array, baked.mesh.skinIndex),
674
+ skinWeight: fromB64(Float32Array, baked.mesh.skinWeight),
675
+ index: fromB64(Uint32Array, baked.mesh.index),
676
+ },
677
+ };
678
+ const boneNames = skeletonJson.bones.map((b) => b.name);
679
+ if (mod.clips) {
680
+ const defs = typeof mod.clips === 'function' ? mod.clips(model) : mod.clips;
681
+ const generated = buildGeneratedClips(defs, boneNames, Object.keys(baked.clips));
682
+ generatedNames = Object.keys(generated);
683
+ Object.assign(skeletonJson.clips, generated);
684
+
685
+ // The broadphase sphere must contain every pose the LAYERS can reach —
686
+ // the in-page measure only saw the GLB's own clips. Skin the mesh
687
+ // through each generated clip alone at weight 1 and add each clip's
688
+ // worst vertex displacement to the radius. A sum over clips is
689
+ // deliberately conservative (layers combine): an oversized sphere only
690
+ // costs broadphase efficiency, an undersized one silently discards
691
+ // true hits on a flared wing. Weights are expected in [0, 1].
692
+ const rigLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/rig.js')).href);
693
+ const skinLib = await import(pathToFileURL(engineFile(dir, 'shared/ui/room/lib/skin-hit.js')).href);
694
+ const rig = rigLib.createRig(skeletonJson);
695
+ const root = { t: skeletonJson.root.t, r: skeletonJson.root.r, s: skeletonJson.root.s };
696
+ const skinned = skinLib.createSkinnedMesh({
697
+ vertexCount: model.mesh.vertexCount, influences: model.mesh.influences,
698
+ position: model.mesh.position, skinIndex: model.mesh.skinIndex,
699
+ skinWeight: model.mesh.skinWeight, index: model.mesh.index,
700
+ invBind: skinLib.restInvBind(rig, root),
701
+ });
702
+ const rest = Float32Array.from(skinLib.skin(skinned, rigLib.evaluate(rig, { root })));
703
+ let inflate = 0;
704
+ for (const name of generatedNames) {
705
+ const clip = skeletonJson.clips[name];
706
+ const STEPS = clip.duration > 0 ? 24 : 1;
707
+ let worst = 0;
708
+ for (let s = 0; s <= STEPS; s++) {
709
+ const t = clip.duration > 0 ? (clip.duration * s) / STEPS : 0;
710
+ const sample = rigLib.sampleAdditive(rig, name, t, 1);
711
+ const posed = skinLib.skin(skinned, rigLib.evaluate(rig, { ...sample, root }));
712
+ for (let v = 0; v < model.mesh.vertexCount * 3; v += 3) {
713
+ const d = Math.hypot(posed[v] - rest[v], posed[v + 1] - rest[v + 1], posed[v + 2] - rest[v + 2]);
714
+ if (d > worst) worst = d;
715
+ }
716
+ }
717
+ inflate += worst;
718
+ }
719
+ if (inflate > 0) baked.mesh.radius = (baked.mesh.radius + inflate) * 1.03;
720
+ }
721
+ if (mod.zones) {
722
+ if (cfg.zones && Object.keys(cfg.zones).length) {
723
+ throw new Error(`${basename(paths.config)} has BOTH a "zones" bone-name map and a generator exporting zones() — pick one strategy per model`);
724
+ }
725
+ const raw = typeof mod.zones === 'function' ? mod.zones(model) : mod.zones;
726
+ genZones = validateGeneratedZones(raw, baked.mesh.triangleCount);
727
+ const b64 = Buffer.from(genZones.tri).toString('base64');
728
+ baked.mesh.zones = { names: genZones.names, tri: { b64 } };
729
+ const counts = {};
730
+ genZones.names.forEach((n) => { counts[n] = 0; });
731
+ for (const z of genZones.tri) counts[genZones.names[z]]++;
732
+ baked.stats.zoneCounts = counts;
733
+ }
734
+ }
735
+
736
+ const source = { hash: sourceHash(glbBytes, configText, genText), glb: basename(abs), bakeVersion: BAKE_VERSION };
482
737
  const note = 'GENERATED by the Looop model baker — do not hand-edit; edit the GLB or its .bake.json and re-bake.';
483
738
  writeFileSync(paths.skeleton, JSON.stringify({
484
739
  _generated: note,
485
740
  _source: source,
486
- root: { t: baked.root.t, r: baked.root.r, s: rootS },
487
- bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
488
- clips: baked.clips,
741
+ ...skeletonJson,
489
742
  }, null, 1) + '\n');
490
743
  writeFileSync(paths.mesh, JSON.stringify({ _generated: note, _source: source, ...baked.mesh }) + '\n');
491
744
 
492
745
  const s = baked.stats;
493
746
  log(`baked ${basename(abs)} → ${basename(paths.skeleton)} + ${basename(paths.mesh)}`);
494
- log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${s.radius.toFixed(3)}`);
747
+ log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${baked.mesh.radius.toFixed(3)}`);
495
748
  log(` clips: ${s.clips.length ? s.clips.join(', ') : '(none in this GLB)'}`);
749
+ if (generatedNames.length) log(` generated additive clips: ${generatedNames.join(', ')}`);
496
750
  if (s.zoneCounts) log(` zones: ${Object.entries(s.zoneCounts).map(([k, v]) => `${k} ${v}`).join(' ')}`);
497
751
  for (const n of baked.notes) log(` note: ${n}`);
498
752
  return { paths, stats: s, notes: baked.notes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",