spine-rigc 0.2.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/cli.ts ADDED
@@ -0,0 +1,739 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * rigc — the rig compiler.
4
+ *
5
+ * bun cli.ts build --rig <path> --motion <path> --out <dir> [--manifest <path>]
6
+ * bun cli.ts build --cut <name> --cuts <cuts.json>
7
+ * bun cli.ts explain --rig <path> --motion <path> --out <dir>
8
+ * bun cli.ts explain --cut <name> --cuts <cuts.json>
9
+ * bun cli.ts validate <dir> re-run the gate on artifacts on disk
10
+ * bun cli.ts check --candidate <dir> --frames <dir> compare against pictures
11
+ *
12
+ * `build` emits ONLY if validate is green. That ordering is the point: the
13
+ * compiler is allowed to be wrong, it is not allowed to leave the wrong thing
14
+ * on disk.
15
+ *
16
+ * ⚠️ Green is a claim about VALIDITY and about nothing else. The gate has no way
17
+ * to know whether the animation is the one that was asked for — a build with
18
+ * every easing reversed passes it — so `check` is the other half of the loop, and
19
+ * it is a separate command because it needs something the gate does not have: a
20
+ * picture of what the result is supposed to look like.
21
+ *
22
+ * rigc knows nothing about any particular project. A cut is a rig spec, a motion
23
+ * spec and an output directory — plus a cut manifest when there is measured art
24
+ * behind it — and a `cuts.json` is a named table of them:
25
+ *
26
+ * {
27
+ * "my_cut": { "rig": "…/rigs/my_rig.rig.json", "motion": "…/my.motion.json",
28
+ * "out": "…/spine", "manifest": "…/manifest.json" }
29
+ * }
30
+ *
31
+ * Its paths resolve against the cuts.json file itself, so the table travels
32
+ * with the project that owns the art rather than with this repository.
33
+ */
34
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
35
+ import { dirname, join, resolve } from 'node:path';
36
+ import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
37
+ import { compile, CompileError, type CompileOptions } from './src/compile.ts';
38
+ import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
39
+ import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
40
+ import { DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
41
+ import type { CompileResult, MotionSpec } from './src/types.ts';
42
+
43
+ /**
44
+ * One entry of a cuts.json, every path relative to the cuts.json file.
45
+ *
46
+ * `rig` is required — it is the skeleton's structure, and until it was a file
47
+ * that structure was three hard-coded tables in the compiler. `manifest` is
48
+ * optional: a skeleton with no measured art behind it has none, and then the rig
49
+ * spec carries its own attachments and stage size.
50
+ */
51
+ export interface CutEntry {
52
+ rig: string;
53
+ motion: string;
54
+ out: string;
55
+ manifest?: string;
56
+ /** Base directory for the rig spec's `image` references, if not the rig's own. */
57
+ images?: string;
58
+ }
59
+
60
+ export type CutTable = Record<string, CutEntry>;
61
+
62
+ class UsageError extends Error {}
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // argument parsing
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * The flags that are switches rather than `--flag value` pairs.
70
+ *
71
+ * Listed by name rather than inferred from "the next argument looks like a
72
+ * flag": inferring it would turn `--out --json report.json` — a real typo, a
73
+ * missing value — into a silently accepted switch plus a stray positional.
74
+ */
75
+ const BOOLEAN_FLAGS = new Set(['all-frames']);
76
+
77
+ /** `--flag value` pairs plus the leftover positionals, in order. */
78
+ function parseArgs(argv: string[]): { flags: Record<string, string>; positional: string[] } {
79
+ const flags: Record<string, string> = {};
80
+ const positional: string[] = [];
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const arg = argv[i];
83
+ if (arg.startsWith('--')) {
84
+ const eq = arg.indexOf('=');
85
+ if (eq !== -1) {
86
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
87
+ } else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
88
+ flags[arg.slice(2)] = 'true';
89
+ } else {
90
+ const next = argv[i + 1];
91
+ if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
92
+ flags[arg.slice(2)] = next;
93
+ i++;
94
+ }
95
+ } else {
96
+ positional.push(arg);
97
+ }
98
+ }
99
+ return { flags, positional };
100
+ }
101
+
102
+ /**
103
+ * Read a cuts.json and resolve its three paths against the file's own
104
+ * directory. Anchoring on the table rather than on the process cwd is what lets
105
+ * the same command work from anywhere in the owning project.
106
+ */
107
+ function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
108
+ const abs = resolve(cutsPath);
109
+ if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
110
+ const parsed: unknown = JSON.parse(readFileSync(abs, 'utf8'));
111
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
112
+ throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
113
+ }
114
+ return { dir: dirname(abs), table: parsed as CutTable };
115
+ }
116
+
117
+ function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOptions {
118
+ for (const key of ['rig', 'motion', 'out'] as const) {
119
+ if (typeof entry?.[key] !== 'string') throw new UsageError(`cut ${JSON.stringify(name)} has no "${key}" path`);
120
+ }
121
+ const opts: CompileOptions = {
122
+ rigPath: resolve(dir, entry.rig),
123
+ motionPath: resolve(dir, entry.motion),
124
+ outDir: resolve(dir, entry.out),
125
+ };
126
+ if (entry.manifest !== undefined) opts.manifestPath = resolve(dir, entry.manifest);
127
+ if (entry.images !== undefined) opts.imagesDir = resolve(dir, entry.images);
128
+ return opts;
129
+ }
130
+
131
+ /**
132
+ * Resolve the cut a command was pointed at, either spelled out on the command
133
+ * line or looked up by name in a cuts.json.
134
+ */
135
+ function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
136
+ const explicit =
137
+ flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
138
+ if (explicit) {
139
+ if (flags.cut !== undefined || flags.cuts !== undefined) {
140
+ throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
141
+ }
142
+ for (const key of ['rig', 'motion', 'out'] as const) {
143
+ if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
144
+ }
145
+ const opts: CompileOptions = {
146
+ rigPath: resolve(flags.rig),
147
+ motionPath: resolve(flags.motion),
148
+ outDir: resolve(flags.out),
149
+ };
150
+ if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
151
+ if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
152
+ return { label: flags.rig, opts };
153
+ }
154
+ if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
155
+ if (flags.cuts === undefined) throw new UsageError('--cut needs --cuts <cuts.json> to look the name up in');
156
+ const { dir, table } = readCutTable(flags.cuts);
157
+ const entry = table[flags.cut];
158
+ if (!entry) {
159
+ throw new UsageError(
160
+ `unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
161
+ );
162
+ }
163
+ return { label: flags.cut, opts: entryToOptions(dir, flags.cut, entry) };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // commands
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Read `--profile`, defaulting to the profile every caller had before the flag
172
+ * existed. An unknown name is a usage error rather than a silent fallback: the
173
+ * fallback would be `spine-html`, so a typo would quietly re-apply the strictest
174
+ * rulebook to data the caller was trying to exempt.
175
+ */
176
+ function readProfile(flags: Record<string, string>): ValidateProfile {
177
+ const raw = flags.profile;
178
+ if (raw === undefined) return DEFAULT_PROFILE;
179
+ const found = VALIDATE_PROFILES.find((p) => p === raw);
180
+ if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
181
+ return found;
182
+ }
183
+
184
+ function runGate(result: CompileResult, opts: CompileOptions, profile: ValidateProfile): number {
185
+ // The determinism check compares a second, independent compile.
186
+ const again = compile(opts);
187
+ const report = validate({
188
+ skeletonText: result.skeletonText,
189
+ atlasText: result.atlasText,
190
+ atlasDir: opts.outDir,
191
+ declaredDurations: result.declaredDurations,
192
+ reEmit: { skeletonText: again.skeletonText, atlasText: again.atlasText },
193
+ rig: result.rig,
194
+ profile,
195
+ });
196
+ for (const line of reportLines(report)) console.log(line);
197
+ console.log(
198
+ ` .. ${Object.entries(report.stats)
199
+ .map(([k, v]) => `${k}=${v}`)
200
+ .join(' ')}`,
201
+ );
202
+ return report.failures.length;
203
+ }
204
+
205
+ function cmdBuild(flags: Record<string, string>): void {
206
+ const { label, opts } = resolveCut(flags);
207
+ const profile = readProfile(flags);
208
+ console.log(`rigc build ${label}`);
209
+ const result = compile(opts);
210
+
211
+ console.log(` .. ${result.images.length} part page(s):`);
212
+ for (const img of result.images) {
213
+ console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${img.page}`);
214
+ }
215
+ for (const d of result.droppedStates) {
216
+ console.log(` DROP ${d.slot}/${d.state}: no PNG at ${d.path} (state not emitted)`);
217
+ }
218
+ // "The optional slots are optional" is a claim about this code path, so this
219
+ // code path says which ones it left out rather than being silently right.
220
+ for (const a of result.absentParts) {
221
+ console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
222
+ }
223
+ for (const m of result.meshes) {
224
+ console.log(
225
+ ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles ` +
226
+ `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]`,
227
+ );
228
+ }
229
+ for (const ph of result.physics) {
230
+ console.log(
231
+ ` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
232
+ `mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
233
+ );
234
+ }
235
+
236
+ console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
237
+ const failures = runGate(result, opts, profile);
238
+ if (failures > 0) {
239
+ console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
240
+ process.exit(1);
241
+ }
242
+
243
+ mkdirSync(opts.outDir, { recursive: true });
244
+ writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
245
+ writeFileSync(join(opts.outDir, 'skeleton.atlas'), result.atlasText);
246
+ console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
247
+ console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
248
+ }
249
+
250
+ /**
251
+ * Where a pair of artifacts lives, given what the caller pointed at.
252
+ *
253
+ * Two shapes, because rigc's own output and a foreign export are named
254
+ * differently and both have to be gateable. rigc writes `skeleton.json` +
255
+ * `skeleton.atlas` into a directory. Everybody else writes whatever the editor
256
+ * called the project, and the official examples are not even consistent with
257
+ * themselves — `7-anticipation/export/` holds `sack-pro.json`, `spineboy/export/`
258
+ * holds two skeletons and two atlases.
259
+ *
260
+ * ⚠️ When more than one atlas sits beside the skeleton, this refuses to choose.
261
+ * Guessing by name would be wrong on the corpus that motivated it: `spineboy-ess`
262
+ * shares a longer prefix with `spineboy-run.atlas` than with the `spineboy.atlas`
263
+ * it actually uses, so the plausible heuristic picks the wrong file and every
264
+ * attachment then resolves against the wrong pixels — silently, which is the
265
+ * exact failure mode this tool exists to remove.
266
+ */
267
+ function resolveArtifacts(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string } {
268
+ const abs = resolve(target);
269
+ if (!existsSync(abs)) throw new UsageError(`nothing at ${abs}`);
270
+ if (statSync(abs).isDirectory()) {
271
+ return {
272
+ skeletonPath: join(abs, 'skeleton.json'),
273
+ atlasPath: atlasFlag ? resolve(atlasFlag) : join(abs, 'skeleton.atlas'),
274
+ };
275
+ }
276
+ if (!abs.endsWith('.json')) throw new UsageError(`${abs} is neither a directory nor a .json skeleton`);
277
+ if (atlasFlag) return { skeletonPath: abs, atlasPath: resolve(atlasFlag) };
278
+ const dir = dirname(abs);
279
+ const atlases = readdirSync(dir)
280
+ .filter((f) => f.endsWith('.atlas'))
281
+ .sort();
282
+ if (atlases.length === 1) return { skeletonPath: abs, atlasPath: join(dir, atlases[0]) };
283
+ if (atlases.length === 0) throw new UsageError(`no .atlas beside ${abs}; name one with --atlas <path>`);
284
+ throw new UsageError(
285
+ `${atlases.length} atlases beside ${abs} (${atlases.join(', ')}); name the right one with --atlas <path> ` +
286
+ '— guessing by filename is how an attachment quietly resolves against the wrong page',
287
+ );
288
+ }
289
+
290
+ function cmdValidate(flags: Record<string, string>, positional: string[]): void {
291
+ // A bare directory validates what is on disk. Naming the cut as well lets the
292
+ // gate re-derive the declared durations and the structural expectations, which
293
+ // a directory alone cannot supply — and the report says which it had.
294
+ const named = flags.cut !== undefined || flags.rig !== undefined;
295
+ const profile = readProfile(flags);
296
+ const derivedOpts = named ? resolveCut(flags).opts : null;
297
+ const { skeletonPath, atlasPath } = resolveArtifacts(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas);
298
+ console.log(`rigc validate ${skeletonPath}`);
299
+ console.log(` .. atlas ${atlasPath}`);
300
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
301
+ const atlasText = readFileSync(atlasPath, 'utf8');
302
+ const derived = derivedOpts ? compile(derivedOpts) : null;
303
+
304
+ const report = validate({
305
+ skeletonText,
306
+ atlasText,
307
+ atlasDir: dirname(atlasPath),
308
+ declaredDurations: derived?.declaredDurations,
309
+ rig: derived?.rig,
310
+ profile,
311
+ });
312
+ for (const line of reportLines(report)) console.log(line);
313
+ if (report.failures.length > 0) {
314
+ console.error(`rigc: ${report.failures.length} assertion(s) failed`);
315
+ process.exit(1);
316
+ }
317
+ console.log('rigc: green');
318
+ }
319
+
320
+ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
321
+ const [candidate, reference] = positional;
322
+ if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
323
+ const candidatePath = resolve(candidate);
324
+ const referencePath = resolve(reference);
325
+ for (const path of [candidatePath, referencePath]) {
326
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
327
+ }
328
+ const report = diffSkeletons(
329
+ JSON.parse(readFileSync(candidatePath, 'utf8')),
330
+ JSON.parse(readFileSync(referencePath, 'utf8')),
331
+ );
332
+ console.log('rigc diff');
333
+ for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
334
+ if (flags.json !== undefined) {
335
+ // ⚠️ `...report` carries its OWN `candidate` and `reference` — the raw
336
+ // per-side counts. Spelling the paths under those two names put them on
337
+ // the losing side of the spread, so the written report named neither file
338
+ // it compared. The paths get their own keys.
339
+ writeJson(flags.json, { candidatePath, referencePath, ...report });
340
+ }
341
+ }
342
+
343
+ /**
344
+ * check — how close does the candidate LOOK to the reference frames?
345
+ *
346
+ * ⭐ The gate cannot see a wrong animation. It parses, it steps, it refuses the
347
+ * degenerate — and a rig whose easings are all reversed passes it green, which is
348
+ * not a hypothetical: ladder rung 1's first honest run shipped exactly that build
349
+ * and the validator was structurally incapable of noticing. `diff` cannot see it
350
+ * either, because it compares structure and a reversed curve is the same curve
351
+ * count. The only thing that can is a picture, so this renders the candidate into
352
+ * the reference's own frame and compares pixels.
353
+ *
354
+ * 🔒 It never reads the reference skeleton — see `src/check.ts`. That is what
355
+ * keeps it usable **inside** an authoring loop rather than at the finish line the
356
+ * way `bench` is: an author may run it as often as they like without their run
357
+ * stopping being an authoring run.
358
+ *
359
+ * There is no pass mark, for the same reason `diff` has none.
360
+ */
361
+ function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as'> {
362
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as'> = {};
363
+ if (flags.fps !== undefined) {
364
+ const fps = Number(flags.fps);
365
+ if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
366
+ out.fps = fps;
367
+ }
368
+ if (flags.viewport !== undefined) {
369
+ const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
370
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
371
+ throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
372
+ }
373
+ if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
374
+ out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
375
+ }
376
+ if (flags.as !== undefined) out.as = flags.as;
377
+ return out;
378
+ }
379
+
380
+ function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: string, flags: Record<string, string>): CheckReport {
381
+ const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
382
+ return checkAgainstFrames({
383
+ skeletonText: readFileSync(skeletonPath, 'utf8'),
384
+ atlasText: readFileSync(atlasPath, 'utf8'),
385
+ atlasDir: dirname(atlasPath),
386
+ framesDir,
387
+ labels: { skeleton: skeletonPath, atlas: atlasPath },
388
+ ...readCheckFlags(flags),
389
+ });
390
+ }
391
+
392
+ function cmdCheck(flags: Record<string, string>): void {
393
+ if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
394
+ if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
395
+ const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
396
+ console.log('rigc check');
397
+ for (const line of checkLines(report, { allFrames: flags['all-frames'] !== undefined })) console.log(line);
398
+ if (flags.json !== undefined) writeJson(flags.json, report);
399
+ }
400
+
401
+ function writeJson(target: string, body: unknown): void {
402
+ const out = resolve(target);
403
+ mkdirSync(dirname(out), { recursive: true });
404
+ writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
405
+ console.log(`rigc: wrote ${out}`);
406
+ }
407
+
408
+ /**
409
+ * bench — run one rung of the benchmark ladder against a candidate rig.
410
+ *
411
+ * Two questions, asked in this order and never merged:
412
+ *
413
+ * 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
414
+ * and it is the only part with a pass/fail. `spine-html` is not the default
415
+ * here (it is everywhere else): the thing being reproduced is an editor
416
+ * export, and holding it to this project's renderer policy would fail rungs
417
+ * for reasons the rung is not about.
418
+ * 2. How close is it, structurally, to the reference? That is `diff`, and it
419
+ * has no threshold at all. There is no score to pass, on purpose — see
420
+ * `src/diff.ts`. A rung is called cleared by a human reading the measures,
421
+ * and `docs/LADDER.md` records that judgement.
422
+ *
423
+ * ⚠️ The candidate is validated against the SPINE profile and compared against
424
+ * the reference; the reference is never validated here. It is editor output and
425
+ * is the definition of correct for this exercise, so gating it would be gating
426
+ * the yardstick with the ruler.
427
+ */
428
+ function cmdBench(flags: Record<string, string>, positional: string[]): void {
429
+ const rungId = positional[0];
430
+ if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
431
+ const rung = findRung(rungId);
432
+ if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
433
+ if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
434
+
435
+ // bench judges a reproduction of editor output, so `spine` is the default.
436
+ const profile = flags.profile === undefined ? 'spine' : readProfile(flags);
437
+ const exportDir = resolve(import.meta.dir, 'examples', rung.example, 'export');
438
+ if (!existsSync(exportDir)) {
439
+ throw new UsageError(
440
+ `no example corpus at ${exportDir} — run \`bun run fetch-examples\` first (examples/ is gitignored, not shipped)`,
441
+ );
442
+ }
443
+
444
+ const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
445
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
446
+ const atlasText = readFileSync(atlasPath, 'utf8');
447
+
448
+ console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
449
+ console.log(` gates ${rung.gates}`);
450
+ console.log(` candidate ${skeletonPath}`);
451
+ console.log(` atlas ${atlasPath}`);
452
+ console.log('');
453
+
454
+ console.log(` ── validate (profile ${profile}) ──`);
455
+ const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), profile });
456
+ for (const line of reportLines(report)) console.log(` ${line}`);
457
+ console.log('');
458
+
459
+ const candidateJson: unknown = JSON.parse(skeletonText);
460
+ const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
461
+ for (const skeleton of rung.skeletons) {
462
+ const referencePath = join(exportDir, skeleton.file);
463
+ if (!existsSync(referencePath)) {
464
+ console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
465
+ continue;
466
+ }
467
+ const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
468
+ console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
469
+ const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
470
+ for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
471
+ console.log('');
472
+ diffs.push({ skeleton, reference: referencePath, report: diff });
473
+ }
474
+
475
+ // Third, optional and third for a reason: is it the same MOTION? `diff`
476
+ // compares structure, and a reversed easing is the same key count and the same
477
+ // curve kind — so a row of this ladder carrying only `validate` and `diff`
478
+ // records a rig that could be animated backwards. `--frames` folds `check`'s
479
+ // table into the report so a future row carries both.
480
+ let check: CheckReport | null = null;
481
+ if (flags.frames !== undefined) {
482
+ console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
483
+ check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
484
+ for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
485
+ console.log('');
486
+ }
487
+
488
+ console.log(' ── summary ──');
489
+ console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
490
+ for (const d of diffs) {
491
+ const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
492
+ console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
493
+ // Second line, not folded into the first: the figures above are the ones
494
+ // every bench.json on disk already carries, and a ladder record is worth
495
+ // less the moment its headline stops meaning what the older ones meant.
496
+ // The sections whose measures are dominated by name-keyed ones get their
497
+ // name-agnostic figure printed beside — issue #21.
498
+ const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
499
+ if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
500
+ }
501
+ if (check) {
502
+ // The framing goes first because it is upstream of every MAE below it: a
503
+ // summary that reported those numbers without saying how the two shots were
504
+ // put on each other is how issue #34 stayed invisible for two ladder runs.
505
+ const framing = check.framingFit;
506
+ if (framing) {
507
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
508
+ const how = !framing.applied
509
+ ? 'measured only — --viewport pinned'
510
+ : framing.source === 'declared'
511
+ ? `frames.json's own box, the candidate measured into it`
512
+ : `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
513
+ console.log(
514
+ ` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
515
+ `${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})`,
516
+ );
517
+ }
518
+ for (const anim of check.animations) {
519
+ const attributed = anim.compared - anim.framesWithoutDrift;
520
+ const drift =
521
+ anim.worstDriftFrame < 0
522
+ ? 'no slot attributable in any of them'
523
+ : `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
524
+ // The per-frame change count is carried here and not only in `check`'s own
525
+ // table because it is the one figure a flat MAE cannot imply: a shot can be
526
+ // right at every frame and still hold or blink at the wrong moments.
527
+ const change =
528
+ anim.changeDisagreements === 0
529
+ ? ''
530
+ : `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
531
+ console.log(
532
+ ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
533
+ `over ${anim.compared} frame(s) ${drift}${change}`,
534
+ );
535
+ }
536
+ } else {
537
+ console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
538
+ console.log(' Without it this report says nothing about whether the ANIMATION is right.');
539
+ }
540
+ console.log(' Section figures are means of their own measures. There is no rung score:');
541
+ console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
542
+
543
+ if (flags.json !== undefined) {
544
+ writeJson(flags.json, {
545
+ rung: rung.id,
546
+ example: rung.example,
547
+ gates: rung.gates,
548
+ profile,
549
+ candidate: { skeleton: skeletonPath, atlas: atlasPath },
550
+ validate: report,
551
+ // `referencePath`, not `reference`: a DiffReport already has a
552
+ // `reference` of its own (the raw counts), and the spread wins.
553
+ diffs: diffs.map((d) => ({
554
+ label: d.skeleton.label,
555
+ role: d.skeleton.role,
556
+ referencePath: d.reference,
557
+ ...d.report,
558
+ })),
559
+ check,
560
+ });
561
+ }
562
+
563
+ if (report.failures.length > 0) {
564
+ console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
565
+ process.exit(1);
566
+ }
567
+ }
568
+
569
+ function cmdExplain(flags: Record<string, string>): void {
570
+ const { label, opts } = resolveCut(flags);
571
+ const result = compile(opts);
572
+ const motion = JSON.parse(readFileSync(opts.motionPath, 'utf8')) as MotionSpec;
573
+
574
+ console.log(`rigc explain ${label}`);
575
+ console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
576
+
577
+ // The crop note describes where the numbers CAME from, and without a manifest
578
+ // they came from the rig spec's own literals — there is no crop to be relative
579
+ // to. Printing it anyway told a rung-3 author their bone positions were in a
580
+ // coordinate system that did not exist in their rig.
581
+ const frame = opts.manifestPath ? ' (crop y-down -> spine y-up, origin at the bottom-left of the crop)' : ' (spine world: y up)';
582
+ console.log(`\nbones${frame}`);
583
+ for (const b of result.skeleton.bones) {
584
+ // `rotation` is the axis keystone and the grips' radial facing, so it earns
585
+ // a column even though it is absent on most bones.
586
+ const rot = b.rotation === undefined ? '' : ` rotation=${b.rotation}`;
587
+ console.log(` ${b.name.padEnd(12)} parent=${(b.parent ?? '-').padEnd(10)} x=${b.x ?? 0} y=${b.y ?? 0}${rot}`);
588
+ }
589
+
590
+ console.log('\nslots (array order IS the draw order)');
591
+ for (const s of result.skeleton.slots) {
592
+ const atts = Object.keys(result.skeleton.skins[0].attachments[s.name] ?? {});
593
+ console.log(
594
+ ` ${s.name.padEnd(12)} bone=${s.bone.padEnd(12)} setup=${(s.attachment ?? 'null').padEnd(22)} color=${s.color ?? 'ffffffff'} attachments=[${atts.join(', ')}]`,
595
+ );
596
+ }
597
+
598
+ console.log('\nanimations');
599
+ for (const [animName, anim] of Object.entries(result.skeleton.animations)) {
600
+ const spec = motion.animations[animName];
601
+ console.log(` ${animName} declared=${spec.duration}s loop=${spec.loop}`);
602
+ for (const [boneName, timelines] of Object.entries(anim.bones ?? {})) {
603
+ // "(mesh tier)" is a claim about what the bone DRIVES, and it was printed
604
+ // on every bone track regardless — which reads, on a rig with no mesh in
605
+ // it at all, as though the track were deforming one.
606
+ const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
607
+ for (const [timelineName, keys] of Object.entries(timelines)) {
608
+ console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
609
+ for (const key of keys) {
610
+ const fields = Object.entries(key)
611
+ .filter(([k]) => k !== 'time' && k !== 'curve')
612
+ .map(([k, v]) => `${k}=${String(v)}`)
613
+ .join(' ');
614
+ const curve = Array.isArray(key.curve)
615
+ ? `bezier[${key.curve.length}]`
616
+ : key.curve === 'stepped'
617
+ ? 'stepped'
618
+ : 'linear';
619
+ console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}`);
620
+ }
621
+ }
622
+ }
623
+ for (const [slotName, timelines] of Object.entries(anim.slots ?? {})) {
624
+ for (const [timelineName, keys] of Object.entries(timelines)) {
625
+ console.log(` ${slotName}.${timelineName} ${keys.length} key(s)`);
626
+ for (const key of keys) {
627
+ const curve = key.curve;
628
+ const shape = Array.isArray(curve)
629
+ ? `bezier[${curve.length}] ${curve.slice(12).join(', ')} <- alpha channel, absolute (t,v)`
630
+ : curve === 'stepped'
631
+ ? 'stepped'
632
+ : timelineName === 'attachment'
633
+ ? 'stepped (attachment timelines always are)'
634
+ : 'linear';
635
+ const value = 'color' in key ? `#${String(key.color)}` : `attachment=${String(key.name)}`;
636
+ console.log(` t=${String(key.time).padEnd(7)} ${value.padEnd(30)} ${shape}`);
637
+ }
638
+ }
639
+ }
640
+ // The draw-order timeline names no target, so it hangs off the animation
641
+ // rather than off a slot — and a timeline `explain` did not print would be a
642
+ // timeline nobody could check without reading the emitted JSON.
643
+ if (anim.drawOrder) {
644
+ console.log(` drawOrder ${anim.drawOrder.length} key(s) <- whole animation, offsets against the SETUP order`);
645
+ for (const key of anim.drawOrder) {
646
+ const offsets = Array.isArray(key.offsets)
647
+ ? (key.offsets as Array<{ slot: string; offset: number }>)
648
+ .map((o) => `${o.slot}${o.offset >= 0 ? '+' : ''}${o.offset}`)
649
+ .join(' ')
650
+ : 'back to the setup order';
651
+ console.log(` t=${String(key.time).padEnd(7)} ${offsets}`);
652
+ }
653
+ }
654
+ }
655
+
656
+ if (result.physics.length) {
657
+ console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
658
+ for (const ph of result.physics) {
659
+ console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
660
+ }
661
+ }
662
+
663
+ if (result.meshes.length) {
664
+ console.log('\nmeshes (ring tier: rim ring pinned on the window edge, seam ring pinned on the mask contour)');
665
+ for (const m of result.meshes) {
666
+ console.log(
667
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]`,
668
+ );
669
+ }
670
+ }
671
+
672
+ if (result.droppedStates.length) {
673
+ console.log('\ndropped states (listed in the manifest, no PNG on disk)');
674
+ for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${d.path}`);
675
+ }
676
+
677
+ console.log('\nmix table (player config, not skeleton JSON)');
678
+ console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
679
+ }
680
+
681
+ const USAGE = [
682
+ 'usage:',
683
+ ' bun cli.ts build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>]',
684
+ ' bun cli.ts build --cut <name> --cuts <cuts.json>',
685
+ ' bun cli.ts explain (same arguments as build)',
686
+ ' bun cli.ts validate <dir | skeleton.json> [--atlas <path>]',
687
+ ' bun cli.ts diff <candidate.json> <reference.json> [--json <out>]',
688
+ ' bun cli.ts check --candidate <dir | skeleton.json> --frames <dir>',
689
+ ` bun cli.ts bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>]`,
690
+ '',
691
+ 'build and validate take --profile spine|spine-html (default spine-html):',
692
+ ' spine is this valid Spine 4.3 that any runtime plays correctly?',
693
+ ' spine-html the above, plus this project\'s renderer and archetype policy.',
694
+ '',
695
+ 'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
696
+ 'there by its own drawn pixels, and compares. It reads the frames and never the',
697
+ 'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
698
+ 'see a wrong animation and this can:',
699
+ ' --frames <dir> a rendered frame set (a skeleton root, or one animation dir)',
700
+ ' --atlas <path> the candidate\'s atlas, when it is not beside the skeleton',
701
+ ' --fps <n> only for a frame set with no frames.json sidecar',
702
+ ' --viewport x,y,w,h pin the candidate\'s world box, y up, instead of fitting it',
703
+ ' --as <name> the candidate animation to play, when it is named differently',
704
+ ' --all-frames print every frame, not just the worst by MAE',
705
+ ' --json <out> the whole per-frame, per-slot report',
706
+ '',
707
+ 'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
708
+ ' "manifest": "..." (optional) } }, with every path',
709
+ 'resolved relative to the cuts.json file itself.',
710
+ ].join('\n');
711
+
712
+ const [command, ...rest] = process.argv.slice(2);
713
+ try {
714
+ const { flags, positional } = parseArgs(rest);
715
+ if (command === 'build') cmdBuild(flags);
716
+ else if (command === 'validate') cmdValidate(flags, positional);
717
+ else if (command === 'explain') cmdExplain(flags);
718
+ else if (command === 'diff') cmdDiff(flags, positional);
719
+ else if (command === 'check') cmdCheck(flags);
720
+ else if (command === 'bench') cmdBench(flags, positional);
721
+ else {
722
+ console.error(USAGE);
723
+ process.exit(2);
724
+ }
725
+ } catch (err) {
726
+ if (err instanceof UsageError) {
727
+ console.error(`rigc: ${err.message}\n\n${USAGE}`);
728
+ process.exit(2);
729
+ }
730
+ if (err instanceof CompileError) {
731
+ console.error(`rigc compile error: ${err.message}`);
732
+ process.exit(1);
733
+ }
734
+ if (err instanceof CheckError) {
735
+ console.error(`rigc check error: ${err.message}`);
736
+ process.exit(1);
737
+ }
738
+ throw err;
739
+ }