spine-rigc 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -503,7 +503,7 @@ commands take it and what its default is.
503
503
  | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
504
504
  | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
505
505
  | `validate <dir>` | re-gates artifacts already on disk |
506
- | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h` supplies the one value a skeleton does not hold, and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
506
+ | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h` supplies the stage for a skeleton that declares none, and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
507
507
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
508
508
  | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
509
509
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
@@ -613,7 +613,12 @@ export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
613
613
  animation **for each skin the build declares** — one render-and-check block per
614
614
  skin, with a per-skin roll-up under them, because a rig's contested art lives in
615
615
  its named skins and a single un-skinned check draws none of it — and a
616
- field-by-field list of what the editor rewrote. On its first
616
+ field-by-field list of what the editor rewrote. Every step quotes what its child
617
+ said when that child did not do what it was for, the renderers included; a skin
618
+ **neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
619
+ red, because `check` had nothing to compare and `diff` and `validate` have
620
+ already measured the rig. One side drawing where the other does not is the
621
+ divergence the trip exists to find and stays a failure. On its first
617
622
  run it found three emitter defects — [#368](https://github.com/firejune/rigc/issues/368),
618
623
  [#369](https://github.com/firejune/rigc/issues/369),
619
624
  [#370](https://github.com/firejune/rigc/issues/370) — and then showed that a
package/cli.ts CHANGED
@@ -2961,9 +2961,11 @@ const FLAG_MEANINGS: Record<string, string> = {
2961
2961
  '--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
2962
2962
  'resolve (default: loose)',
2963
2963
  stage:
2964
- "the setup bounding box — `skeleton.x,y,width,height`. An editor export carries none and rigc refuses a " +
2965
- 'compile without one; posing the rig gives the ANIMATED extent, which is a different number, so this is the ' +
2966
- "caller's value and is never derived. Without it the missing stage is reported as a blocker",
2964
+ 'the setup bounding box — `skeleton.x,y,width,height` — for a skeleton that declares none. It cannot be ' +
2965
+ 'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
2966
+ "is the caller's value, and without it the missing stage is reported as a blocker. ⚠️ An editor export MAY " +
2967
+ 'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
2968
+ 'so the flag is for a file that really has none rather than for editor exports as a class',
2967
2969
  help: "show this command's flags and exit",
2968
2970
  };
2969
2971
 
@@ -3312,9 +3314,10 @@ const USAGE = [
3312
3314
  'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
3313
3315
  'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
3314
3316
  'atlas — so two things are the caller\'s and are refused rather than guessed: the',
3315
- 'setup stage (--stage; an export carries none) and how the spec reaches the art',
3316
- '(--art). --images <dir> is the third and the only optional one: it WRITES the rig',
3317
- 'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
3317
+ 'setup stage (--stage, only when the skeleton itself declares none) and how the spec',
3318
+ 'reaches the art (--art). --images <dir> is the third and the only optional one: it',
3319
+ 'WRITES the rig spec\'s own images directory, relative to --out, so the rebuild needs',
3320
+ 'no flag.',
3318
3321
  'Everything the spec format cannot hold is printed as a named finding and',
3319
3322
  'exits non-zero, with both files still written, because a spec plus a list of what',
3320
3323
  'is missing from it beats no spec at all.',
package/docs/AUTHORING.md CHANGED
@@ -397,7 +397,7 @@ repository builds on every run.
397
397
  | `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
398
398
  | `--art none` | state `width`/`height` only, so the rebuild is `build --atlas-in <pack.atlas>` and every part resolves out of the pack |
399
399
  | `--images <dir>` | **write** the rig spec's own `images` directory, spelled relative to `--out`, so the rebuild is a plain `build --rig … --motion … --out …`. Without it the field is left out and every `image` resolves against `--out` itself, which holds the specs and no art — so every rebuild has to repeat `build --images <dir>`. Refused together with `--art none`, which writes no `image` for it to be the base of |
400
- | `--stage x,y,w,h` | the setup bounding box. **Required for an editor export**, which carries none |
400
+ | `--stage x,y,w,h` | the setup bounding box, **for a skeleton that declares none**. An editor export *may* be one; every export under `examples/` carries a box and `ingest` reads it straight through |
401
401
  | `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
402
402
 
403
403
  ⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
@@ -407,13 +407,26 @@ they name the same field — and `ingest` spells the value with the same functio
407
407
  `build` spells `skeleton.images` with, so a spec and the skeleton it came from say
408
408
  where the parts are in one convention.
409
409
 
410
- 🚨 **The stage is the one value `ingest` will not guess.** rigc always emits
411
- `skeleton.width`/`height` and an editor export never does, so a foreign file needs
412
- `--stage`; without it the missing box is a **blocker**, named. It is not derivable —
413
- posing the rig gives the *animated* extent, which is a different number from the
414
- editor's setup box — and it is the value that costs least to get wrong, because no
415
- measure `diff` reports reads the skeleton header at all. Supply it from the project
416
- the file came from, or from the editor's own canvas.
410
+ 🚨 **The stage is one of the two values `ingest` will not guess.** A skeleton JSON
411
+ *need not* carry `skeleton.width`/`height`, and when it does not rigc cannot derive
412
+ one — posing the rig gives the *animated* extent, which is a different number from the
413
+ editor's setup box. So a file that declares none is a **blocker**, named, unless
414
+ `--stage x,y,w,h` supplies it; supply it from the project the file came from, or from
415
+ the editor's own canvas.
416
+
417
+ ⚠️ **This said an editor export "never" carries one until
418
+ [#594](https://github.com/firejune/rigc/issues/594) measured the corpus.** All twelve
419
+ exports under `examples/` declare `x`, `y`, `width` and `height`, `ingest` takes the
420
+ early return on every one of them, and not one needs the flag. What an editor export
421
+ *may* do is carry none: a rigc build that declares no stage
422
+ ([#578](https://github.com/firejune/rigc/issues/578)) came back from a Spine 4.3.26
423
+ round trip with a header of `hash`, `spine`, `images`, `audio` and **no box at all** —
424
+ the editor preserves the absence rather than inventing a stage
425
+ ([#616](https://github.com/firejune/rigc/issues/616)). So `--stage` is for a file that
426
+ really has none, and this repository's corpus holds no example of one. It is still the
427
+ value that costs least to get wrong: `diff` reports the box as `stage_present` and
428
+ `stage_box` ([#578](https://github.com/firejune/rigc/issues/578)) and both are
429
+ `(reported)`, so nothing on the ladder consults them.
417
430
 
418
431
  ⚠️ **The duration is a convention, and it is recorded as one.** Skeleton JSON has no
419
432
  duration field. The largest key time is the only derivable answer and it is what a
@@ -822,7 +835,17 @@ Three readings stay apart, and the middle one is the point of the other two:
822
835
  reports **SKIP** on one, because there is no full frame for a mesh to span. And
823
836
  `rigc diff` reports it — `skeleton.stage_present` and `skeleton.stage_box`, in the
824
837
  header block at the top of the report — so a stage somebody invented now reads
825
- below 1.000 against a source that has none. Both are reported and gate nothing, for
838
+ below 1.000 against a source that has none.
839
+
840
+ ⭐ **A stage at `0,0` is not a stage-less one, and an editor export spells it by
841
+ saying nothing.** The editor omits a header field that is at its default, so a
842
+ skeleton whose box sits at the origin exports as a `width` and a `height` with no
843
+ `x`/`y`; `stage_box` reads that omission as the `0` it means, and its line says so
844
+ — *the extent as stated, an omitted origin as the 0 it means*. So `4/4` on a build
845
+ of yours against an export of that same build is the right answer rather than a
846
+ tolerance, and an origin that really did move still reads below 1.000. The extent
847
+ is the half that is read exactly as stated: omit a `width` and you have declared no
848
+ stage, which `stage_present` is the measure of. Both are reported and gate nothing, for
826
849
  the reason every reported measure is: no reading of the rendered frames could have
827
850
  decided a setup-pose bounding box. The measure inventory that says so lives in
828
851
  [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md), which
@@ -927,7 +950,12 @@ pointing into it is not.
927
950
  ⚠️ **`rigc render` still refuses such a build**, by name and before it draws
928
951
  anything: `… posed no drawable attachment in any animation or in its setup pose —
929
952
  there is nothing to draw`. That is the honest division — the rig is valid Spine
930
- data, and there is no picture of it.
953
+ data, and there is no picture of it. `rigc check` says the same thing one step
954
+ on, since it has no frames to compare; `tools/editor_roundtrip.ts` quotes both
955
+ renderers and reports its step 5 as a **SKIP** naming that, and the round trip
956
+ comes back green on the strength of `validate` and `diff`
957
+ ([#621](https://github.com/firejune/rigc/issues/621)). A skin only **one** side
958
+ can draw is the other case entirely, and stays red.
931
959
 
932
960
  **Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
933
961
  the default `type`:
package/docs/INGEST.md CHANGED
@@ -253,7 +253,7 @@ rigc diff
253
253
 
254
254
  skeleton (reported) (no mean) over 2 measures — the stage, which no reading of the frames could decide
255
255
  1.000 stage_present 1/1 both sides declare a setup-pose stage, or neither does — …
256
- 1.000 stage_box 4/4 the stage is the same box (x, y, width, height, exactly as stated) — …
256
+ 1.000 stage_box 4/4 the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means) — …
257
257
 
258
258
  bones mean 1.000 over 8 measures
259
259
  1.000 count 3/3 how many bones
@@ -288,12 +288,31 @@ units and every one of the 49 measures still reads **1.000**. ⇒ Never take a g
288
288
  `diff` as evidence that a geometric edit did not land, and never take it as evidence
289
289
  that one did.
290
290
 
291
+ ⚠️ **The corpus gate has a value-level measure and this command does not expose it**
292
+ (§2.3, and `docs/BENCHMARK.md`'s *The nine value measures*). The reason is an input
293
+ rather than a policy: comparing values means reading both files through `spine-core`,
294
+ and a skeleton whose attachments carry a `sequence` cannot be parsed without the atlas
295
+ that resolves it — so the measure takes two skeletons **and two packs**, which
296
+ `rigc diff <a.json> <b.json>` does not have. The sentence above is about this command
297
+ and stays true of it.
298
+
291
299
  ⚠️ **The one exception is the skeleton's own declared box**, and it is an exception to
292
300
  the sentence and not to the rule: `skeleton.stage_box` compares four world numbers,
293
- but they are numbers an exporter *wrote into the header* rather than a pose anything
301
+ but they are numbers an exporter *declared in the header* rather than a pose anything
294
302
  measured, and the block they sit in gates nothing. Moving a pivot does not move them
295
303
  either.
296
304
 
305
+ ⭐ **Declared is not the same as written down, and for the origin it is the
306
+ difference between a green round trip and a false finding**
307
+ ([#620](https://github.com/firejune/rigc/issues/620)). The editor omits a header
308
+ field at its default, so a stage sitting at `0,0` exports as a `width` and a
309
+ `height` and no `x`/`y` at all — there is no other spelling for it. The measure
310
+ reads that omission as the `0` it means, which is why a rigc build whose stage is at
311
+ the origin and its own export of that build read `stage_box` **4/4**; reading the
312
+ four "exactly as stated" scored the same box **2/4**. The extent is still read
313
+ exactly as stated: it is what decides whether there is a stage at all, so a missing
314
+ `width` is an absent stage rather than a stage of width zero.
315
+
297
316
  ⛔ **And its ratios are not a score.** [`src/diff.ts`](../src/diff.ts) says so in the
298
317
  type itself (*"Unweighted mean of the measures below. NOT a quality score"*), and the
299
318
  report repeats it at the foot. It measures *agreement with a particular reference*,
@@ -437,9 +456,12 @@ a file rigc did not write.
437
456
  them** ([#594](https://github.com/firejune/rigc/issues/594)). Every
438
457
  `examples/*/export/*.json` is ingested with `--art none`, rebuilt through the pack
439
458
  beside it, and `diff`ed against the file it was read from: **12 of 12 come back with 0
440
- blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones.** Byte
459
+ blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones** — and,
460
+ since [#615](https://github.com/firejune/rigc/issues/615), on all **nine value
461
+ measures** too, over **193,927** compared values. Byte
441
462
  identity is not the claim there and the reason is the input, not the round trip — §2.3
442
- has the three kinds of difference, measured. ⚠️ Which pack is "the one beside it" is
463
+ has the three kinds of difference, measured, and what the value measures do and do not
464
+ reach. ⚠️ Which pack is "the one beside it" is
443
465
  resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, and
444
466
  `spineboy-run.atlas` covers neither skeleton in it.
445
467
 
@@ -605,8 +627,39 @@ rebuild against its source produces differences of exactly three kinds, in every
605
627
  worth being exact about what that does and does not cover. `diff` compares structure —
606
628
  counts, names, parentage, order, timeline kinds, key counts, curve kinds — and **not
607
629
  the values inside the keys**, which is why the precision row above is invisible to it.
608
- On rigc's own rigs byte identity covers both; on a foreign export the values are held
609
- by `check` (pixels) or by nothing, depending on what you render.
630
+ On rigc's own rigs byte identity covers both; on a foreign export it used to be
631
+ `check` (pixels) or nothing, depending on what you render.
632
+
633
+ ⭐ **The values are gated now, and by a second measure rather than by `diff`**
634
+ ([issue #615](https://github.com/firejune/rigc/issues/615)). Structure at 1.000 is
635
+ silent about the numbers inside it: a decompiler that halved every rotation, dropped
636
+ every bone's `length` or mirrored every vertex would read 1.000 on all 49 measures and
637
+ on every `(reported)` one. So the corpus round trip also compares **value by value**,
638
+ with the format's defaults taken from the parser rather than from a table — both files
639
+ are read through `spine-core` and the parsed forms are compared path by path, under a
640
+ tolerance that is the sum of rigc's own 1e-6 emitted grid and one float32 step of the
641
+ runtime's storage. Nine measures, printed on `IG16`'s own line and gated there — here
642
+ is the `6-arcs` export's, wrapped to fit this page:
643
+
644
+ ```
645
+ values: 9/9 measure(s) at 1.000 over 13865 compared value(s); skeleton 1.000 ·
646
+ bones 1.000 · slots 1.000 · attachments 1.000 · constraints 1.000 · events 1.000 ·
647
+ key_times 1.000 · key_values 1.000 · curves 1.000
648
+ ```
649
+
650
+ Over the whole corpus that is **193,927 values** compared, and the twelve read 1.000
651
+ on all nine.
652
+
653
+ `docs/BENCHMARK.md`'s *The nine value measures* is the full statement. What it still
654
+ does **not** cover, in the same breath:
655
+
656
+ | Still uncovered | Why |
657
+ | --- | --- |
658
+ | `version` and `hash` | the rig spec has no field for either, and `ingest` reports both as findings — the header row above, unchanged |
659
+ | anything below one float32 step | the parser stores frames, curves and vertices in a `Float32Array`, so a difference it cannot represent is invisible to any reading of the parsed form |
660
+ | a Bezier's handles *as written* | the parser samples them into the curve, so a moved handle arrives as moved samples rather than as the handle it was |
661
+ | how the file is **spelled** | field order, an omitted default written out, six decimals against eight — the second and third rows of the table above are values that agree, and this measure says so |
662
+ | how it **looks** | that is `check`, and `--texture-from` is how its figure is attributed |
610
663
 
611
664
  The geometric row needs a real number, because a naive reading of `check` makes an
612
665
  exact transcription look wrong. Here is the 3-timing transcription against frames
@@ -1052,7 +1052,7 @@ Nothing structurally new (attachment timeline, blend, inherit all arrived by run
1052
1052
  | (a) | **Transform constraints** — 🔴 first appearance (4). Full 4.3 `source` + `properties{from→to}` model (§1.4), which is the least-documented constraint in the format. ✅ Expressible and round-trips exactly; `RigTransformConstraint` already carried the 4.3 shape |
1053
1053
  | (a) | **Weighted meshes from authored geometry** — 🔴 first appearance. ~~rigc can emit weighted meshes, but only from `buildRingMesh`/`buildRibbonMesh`. An arbitrary 40-vertex/38-triangle mesh cannot be expressed~~ ⚠️ **This was wrong.** `RigMeshAttachment` takes authored `uvs`/`triangles`/`vertices`/`hull`, and `buildRigMesh` copies them verbatim. Both of 6-arcs' meshes round-trip to 1e-5 |
1054
1054
  | (a) | Mesh **`edges`** key ~~(rigc emits `hull` but not `edges`)~~ ✅ emitted from `RigMeshAttachment.edges` and byte-identical to the reference. 🚨 But **nothing measures it** — deleting `edges` from the rig still scores 1.000 on all nine attachment measures, so this rung's own gating feature is invisible to `bench` (issue #46) |
1055
- | (b) | Mesh geometry as data — vertices, triangles, uvs, per-vertex bone weights — instead of a generator name plus a polygon. ✅ Present. ~~🚨 But the weights bind bones by **index into the emitted bone array**, not by name — inserting a bone rebinds every vertex with the gate still green (issue #45)~~ ✅ **Fixed.** Weights bind **by name** (`weights: [[{ bone, x, y, weight }, …], …]`) and the compiler resolves them at emit; an unknown name is a `CompileError` (selftest `R08`). Spine's index run survives behind an explicit `"boneIndexing": "raw"`, whose cost — silence — `MR07` still measures |
1055
+ | (b) | Mesh geometry as data — vertices, triangles, uvs, per-vertex bone weights — instead of a generator name plus a polygon. ✅ Present. ~~🚨 But the weights bind bones by **index into the emitted bone array**, not by name — inserting a bone rebinds every vertex with the gate still green (issue #45)~~ ✅ **Fixed.** Weights bind **by name** (`weights: [[{ bone, x, y, weight }, …], …]`) and the compiler resolves them at emit; an unknown name is a `CompileError` (selftest `RF08`). Spine's index run survives behind an explicit `"boneIndexing": "raw"`, whose cost — silence — `MR07` still measures |
1056
1056
  | (c) | **A20** (unweighted forbidden) is satisfied here. ~~Under `--profile spine-html` its extra clause fires 11 times instead — the editor writes zero-weight bindings and that profile forbids them~~ ✅ **Fixed with #44**: both of A20's policy clauses are statements about what a rigc *generator* produces, so neither applies to authored geometry. Its coherence clauses — present, in range, summing to 1 — still do, in every profile |
1057
1057
  | (c) | **A21_MESH_RIM_PINNED** and **A28** encode ring/ribbon topology and will fire on an arbitrary mesh. ~~✅ **Confirmed**: A21 fires 40 times on the `tail` mesh under the default profile, because `meshKinds` has no entry for an authored mesh and the lookup falls back to `'ring'`~~ ✅ **Fixed** (issue #44): `meshKinds` has a third state, `authored`, and both assertions SKIP on one with that as the reason. The whole transcription is green under the default profile; selftest `MR08` holds it |
1058
1058
  | (c) | A13's mesh budget (≤4 slots, ≤80 tris) is **satisfied** at this rung (2 slots, max 38 tris) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,9 +34,10 @@ rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out
34
34
  ```
35
35
 
36
36
  It reads the skeleton — **only** the skeleton — and writes the rig spec and motion
37
- spec that rebuild it, byte for byte. Two values are not in the file and it refuses
38
- rather than guessing them: the **stage** (`--stage`, an editor export carries none)
39
- and each animation's **duration** (the largest key time, recorded as a finding).
37
+ spec that rebuild it, byte for byte. Two values it refuses rather than guessing: the
38
+ **stage** (`--stage`, for a skeleton that declares none — an editor export *may* be
39
+ one, though every one in the example corpus carries a box) and each animation's
40
+ **duration** (the largest key time, recorded as a finding).
40
41
  Read `findings.json`: a `BLOCK` line means the rebuild will be missing something and
41
42
  the command exits non-zero. Keep the `note` both specs carry. INGEST §2.0.
42
43
 
package/src/diff.ts CHANGED
@@ -66,6 +66,11 @@
66
66
  * instrument — [`bonedist.ts`](bonedist.ts), the ladder's stage 3.
67
67
  */
68
68
  import { walkTimelines } from './timelines.ts';
69
+ // Type only, and erased: the values themselves are read by `skeletonValues` in
70
+ // `src/validate.ts`, which is one of the three modules CLAUDE.md allows to link
71
+ // spine-core. This file stays what its header says it is — see
72
+ // `diffSkeletonValues` for why the reading and the comparing are split there.
73
+ import type { SkeletonValue } from './validate.ts';
69
74
 
70
75
  type Json = Record<string, unknown>;
71
76
 
@@ -1021,7 +1026,11 @@ function diffEvents(c: Json, r: Json): DiffSection {
1021
1026
  */
1022
1027
  interface StageFacts {
1023
1028
  present: boolean;
1024
- /** The four fields as stated, `null` where the header omits one. */
1029
+ /**
1030
+ * The four fields as the header MEANS them: the extent exactly as stated, and
1031
+ * the origin of a declared stage as stated or `0` where it is omitted. See
1032
+ * `stageFacts` for why the second half is a reading and not a fallback.
1033
+ */
1025
1034
  box: Array<number | null>;
1026
1035
  }
1027
1036
 
@@ -1035,11 +1044,48 @@ const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
1035
1044
  * own. It is also what the compiler requires and what `A14_NO_FULL_FRAME_MESH`
1036
1045
  * and `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, so the three agree on the
1037
1046
  * word by construction rather than by memory.
1047
+ *
1048
+ * ⭐ **Inside a declared stage, an omitted `x`/`y` IS `0`** (issue #620). That is
1049
+ * a reading of the format, not a value invented for a gap — the distinction this
1050
+ * file lives or dies by — and four measurements carry it, none of them anybody's
1051
+ * word for the convention:
1052
+ *
1053
+ * - **This tree had already decided it, one file over.** `compile.ts` assembles
1054
+ * the header as `header.x = rig.skeleton?.x ?? 0`, under the same guard: only
1055
+ * when the extent is there. So a rig spec that omits its origin emits `0`, and
1056
+ * reading the same omission in a file as absence made the compiler and the
1057
+ * comparison disagree about one value in one header. This measure is not
1058
+ * adopting the editor's convention so much as stopping contradicting the
1059
+ * emitter it is pointed at.
1060
+ * - **The header's writer omits a field at its default.** All twelve exports
1061
+ * under `examples/` omit `referenceScale`, whose default the parser itself
1062
+ * spells two lines below the four raw assignments (`SkeletonJson.js:74`,
1063
+ * `getValue(skeletonMap, "referenceScale", 100)`), and none of the 389 bone
1064
+ * `x`/`y`/`rotation`/`shearX`/`shearY` values those same files DO write is an
1065
+ * explicit `0`. A stage at the origin therefore has no spelling but the
1066
+ * omission, so reading the omission as absence reads a value the format cannot
1067
+ * express.
1068
+ * - **The binary reader supplies it unconditionally.** `SkeletonBinary.js:69-72`
1069
+ * reads the four as four floats with no key to be missing, so one skeleton's
1070
+ * origin is `0` in a `.skel` and absent in a `.json`. A measure that called
1071
+ * those two different boxes would be reporting the container.
1072
+ * - **The JSON reader's silence is an oversight rather than a meaning.**
1073
+ * `SkeletonJson.js:70-73` is `skeletonData.x = skeletonMap.x`, a raw
1074
+ * assignment that overwrites `SkeletonData`'s own `0` with `undefined`. The
1075
+ * line beside it does the same to `fps`, whose default is `30` and which every
1076
+ * one of the twelve omits — so taking `undefined` for a meaning would say the
1077
+ * editor has never exported a frame rate.
1078
+ *
1079
+ * ⚠️ **The default is the ORIGIN's alone**, and the guard is the paragraph above
1080
+ * it: the extent is what declares a stage, so defaulting it would turn the
1081
+ * stage-less header of issue #578 into a `0x0` stage at `0,0` and answer the
1082
+ * question instead of reading it.
1038
1083
  */
1039
1084
  function stageFacts(root: Json): StageFacts {
1040
1085
  const header = isObj(root.skeleton) ? root.skeleton : {};
1041
1086
  const box = STAGE_FIELDS.map((k) => num(header[k]));
1042
- return { present: num(header.width) !== null && num(header.height) !== null, box };
1087
+ const present = box[2] !== null && box[3] !== null;
1088
+ return { present, box: present ? [box[0] ?? 0, box[1] ?? 0, box[2], box[3]] : box };
1043
1089
  }
1044
1090
 
1045
1091
  /**
@@ -1047,13 +1093,20 @@ function stageFacts(root: Json): StageFacts {
1047
1093
  *
1048
1094
  * ⚠️ **The box is compared EXACTLY, and that is a measurement rather than a
1049
1095
  * choice.** The brief this was built from asked for "the tolerance the other
1050
- * measures use"; `src/diff.ts` has exactly one tolerance in it — `FRAME`, one
1051
- * sixtieth of a second, used once, for `animations.duration` — and no spatial
1052
- * one anywhere, because this file compares no position at all (that is
1096
+ * measures use"; the structural measures in this file have exactly one —
1097
+ * `FRAME`, one sixtieth of a second, used once, for `animations.duration` — and
1098
+ * no spatial one anywhere, because they compare no position at all (that is
1053
1099
  * `bonedist.ts`). A stage is a box an exporter *wrote down*, not a pose anybody
1054
1100
  * measured, so there is nothing for it to be within a tolerance *of*; inventing
1055
1101
  * a spatial epsilon here would be a number nobody measured, in the file whose
1056
1102
  * whole job is to report measured ones.
1103
+ *
1104
+ * ⭐ `diffSkeletonValues` (issue #615) is the second tolerance in the file and
1105
+ * it does not weaken this one. It compares positions, so it needs one; both its
1106
+ * terms are read off other code — rigc's own 1e-6 quantiser and the parser's
1107
+ * float32 storage — rather than chosen here; and the four numbers above are the
1108
+ * one place the two overlap, where `stage_box` stays the stricter reading and
1109
+ * says so by staying exact.
1057
1110
  */
1058
1111
  function diffHeader(c: Json, r: Json): DiffReported {
1059
1112
  const a = stageFacts(c);
@@ -1072,7 +1125,7 @@ function diffHeader(c: Json, r: Json): DiffReported {
1072
1125
  ),
1073
1126
  measure(
1074
1127
  'skeleton.stage_box',
1075
- 'the stage is the same box (x, y, width, height, exactly as stated)',
1128
+ 'the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
1076
1129
  agreed,
1077
1130
  both ? STAGE_FIELDS.length : 0,
1078
1131
  both
@@ -1148,6 +1201,212 @@ export function movedReportedMeasures(report: DiffReport): string[] {
1148
1201
  .map((m) => m.id);
1149
1202
  }
1150
1203
 
1204
+ // ---------------------------------------------------------------------------
1205
+ // The value level — what the structural measures above are blind to
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ /**
1209
+ * rigc's own emitted grid. `r6` in [`compile.ts`](compile.ts) rounds every
1210
+ * emitted number to 1e-6 and `keyTime` rounds a key time DOWN over the same
1211
+ * step, so a rebuild of a file written with more decimals than that may sit up
1212
+ * to one whole step from where it started — through no fault of anything this
1213
+ * measure is looking for.
1214
+ */
1215
+ export const VALUE_EMITTED_GRID = 1e-6;
1216
+
1217
+ /**
1218
+ * One float32 ULP, relative. `spine-core` holds every frame, curve sample and
1219
+ * vertex in a `Float32Array` (`Utils.newFloatArray`), so two decimals that
1220
+ * differ by the grid above can land on adjacent float32s, and the difference
1221
+ * the walk sees is the decimal gap plus that step.
1222
+ *
1223
+ * ⚠️ It is also the floor of what this measure can see AT ALL: a difference
1224
+ * smaller than one float32 step is invisible to the parser and therefore to
1225
+ * this. The tolerance states that rather than hiding it.
1226
+ */
1227
+ export const VALUE_PARSED_ULP = 2 ** -23;
1228
+
1229
+ /**
1230
+ * What two readings of one number are allowed to differ by, and nothing more.
1231
+ *
1232
+ * Both terms are derived rather than fitted: the first is rigc's own quantiser,
1233
+ * the second is the parser's storage. Measured over the twelve editor exports
1234
+ * in `examples/`, the widest gap between a rebuild and the file it was read
1235
+ * from reaches **0.81** of this — so the corpus sits inside a bound that was
1236
+ * not drawn around it.
1237
+ */
1238
+ export function valueTolerance(magnitude: number): number {
1239
+ return VALUE_EMITTED_GRID + VALUE_PARSED_ULP * magnitude;
1240
+ }
1241
+
1242
+ /** Whether two readings of one path agree. `NaN` on both sides is agreement: neither file states it. */
1243
+ function valuesAgree(a: number | string, b: number | string): boolean {
1244
+ if (typeof a !== 'number' || typeof b !== 'number') return a === b;
1245
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
1246
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
1247
+ return Math.abs(a - b) <= valueTolerance(Math.max(Math.abs(a), Math.abs(b)));
1248
+ }
1249
+
1250
+ /**
1251
+ * The measures `diffSkeletonValues` defines, in the order it prints them, and
1252
+ * what each one is. The set is fixed rather than derived from the paths in
1253
+ * front of it, so that a run can say a measure compared NOTHING — the same
1254
+ * distinction `DiffMeasure.total` draws everywhere else in this file.
1255
+ */
1256
+ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }> = [
1257
+ { id: 'values.skeleton', what: 'the header and the setup-pose stage', prefix: 'skeleton/' },
1258
+ { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1259
+ { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1260
+ { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
1261
+ { id: 'values.constraints', what: 'every constraint pose field and flag', prefix: 'constraints/' },
1262
+ { id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
1263
+ { id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
1264
+ { id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
1265
+ { id: 'values.curves', what: 'every curve type and the Bezier samples the parser built from its handles', prefix: 'animations/' },
1266
+ ];
1267
+
1268
+ /**
1269
+ * Which measure a path belongs to. The first path segment decides it, except
1270
+ * under `animations/`, where the three arms are the three questions that fail
1271
+ * differently: a timing that moved, a value that moved, and an easing that
1272
+ * moved. A segment nothing here names becomes a measure of its own rather than
1273
+ * disappearing into one of these — a walk that grows a top-level kind should
1274
+ * arrive as a new line, not as a silently wider denominator.
1275
+ */
1276
+ function valueMeasureFor(path: string): string {
1277
+ const head = path.slice(0, path.indexOf('/') + 1);
1278
+ if (head === 'animations/') {
1279
+ if (path.endsWith('/duration') || /\/key\/\d+\/time$/.test(path)) return 'values.key_times';
1280
+ if (/\/curves\/\d+$/.test(path)) return 'values.curves';
1281
+ return 'values.key_values';
1282
+ }
1283
+ const known = VALUE_MEASURES.find((m) => m.prefix === head);
1284
+ return known === undefined ? `values.${head.slice(0, -1) || path}` : known.id;
1285
+ }
1286
+
1287
+ /** How many offending paths a note spells out before it counts the rest. */
1288
+ const VALUE_OFFENDERS_SPELLED_OUT = 3;
1289
+
1290
+ /**
1291
+ * The value-level comparison: are the numbers inside the structure the same
1292
+ * numbers?
1293
+ *
1294
+ * ## Why it is a separate call rather than a section of `diffSkeletons`
1295
+ *
1296
+ * Because its inputs are not JSON. Every default in the format — `time` absent
1297
+ * is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 while its
1298
+ * `mix` is 1 — has to be applied before two files can be compared value by
1299
+ * value, and writing that table here would be a second spelling of the format
1300
+ * inside the gate. So the defaults come from the parser, through
1301
+ * `skeletonValues` in [`validate.ts`](validate.ts), and this function compares
1302
+ * what it returns: an ordered list of `path → value` with every default already
1303
+ * applied by the one reader that owns them.
1304
+ *
1305
+ * ⚠️ **It reports; it does not gate the ladder.** A rung's brief withholds
1306
+ * coordinates on purpose, so no reading of the reference frames could decide
1307
+ * these — `docs/GATE.md`'s *What never gates* applies word for word. The corpus
1308
+ * gate is the one place they DO decide a verdict, and for the reason `IG16`
1309
+ * already gives about `mesh_edges`: there the reference is the very file the
1310
+ * specs were read from, so a value that moved is a decompiler loss rather than
1311
+ * a candidate's entitlement.
1312
+ *
1313
+ * ## What a difference means, and what it cannot mean
1314
+ *
1315
+ * A path missing on one side is counted as unmatched and named as such: it is a
1316
+ * structural finding too, and the measures above it are where that is
1317
+ * diagnosed. A number present on both sides is compared under
1318
+ * `valueTolerance`, which is derived from rigc's quantiser and the parser's
1319
+ * float32 storage — never from the corpus.
1320
+ */
1321
+ export function diffSkeletonValues(candidate: SkeletonValue[], reference: SkeletonValue[]): DiffMeasure[] {
1322
+ const left = new Map<string, number | string>();
1323
+ for (const v of candidate) left.set(v.path, v.value);
1324
+ const right = new Map<string, number | string>();
1325
+ for (const v of reference) right.set(v.path, v.value);
1326
+
1327
+ interface Tally {
1328
+ matched: number;
1329
+ total: number;
1330
+ offenders: string[];
1331
+ unpaired: number;
1332
+ }
1333
+ const tallies = new Map<string, Tally>();
1334
+ const order: string[] = VALUE_MEASURES.map((m) => m.id);
1335
+ const tallyFor = (id: string): Tally => {
1336
+ const held = tallies.get(id);
1337
+ if (held !== undefined) return held;
1338
+ const fresh: Tally = { matched: 0, total: 0, offenders: [], unpaired: 0 };
1339
+ tallies.set(id, fresh);
1340
+ if (!order.includes(id)) order.push(id);
1341
+ return fresh;
1342
+ };
1343
+ for (const id of order) tallyFor(id);
1344
+
1345
+ // The candidate's paths in the candidate's own order, then whatever the
1346
+ // reference has that the candidate does not — an iteration over the two
1347
+ // lists as they were built, never over a set.
1348
+ for (const { path, value } of candidate) {
1349
+ const tally = tallyFor(valueMeasureFor(path));
1350
+ tally.total++;
1351
+ const other = right.get(path);
1352
+ if (other === undefined) {
1353
+ tally.unpaired++;
1354
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (candidate only)`);
1355
+ continue;
1356
+ }
1357
+ if (valuesAgree(value, other)) {
1358
+ tally.matched++;
1359
+ continue;
1360
+ }
1361
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) {
1362
+ const gap =
1363
+ typeof value === 'number' && typeof other === 'number'
1364
+ ? `, off by ${Math.abs(value - other).toExponential(3)} against ${valueTolerance(
1365
+ Math.max(Math.abs(value), Math.abs(other)),
1366
+ ).toExponential(3)} allowed`
1367
+ : '';
1368
+ tally.offenders.push(`${path} ${JSON.stringify(value)} vs ${JSON.stringify(other)}${gap}`);
1369
+ }
1370
+ }
1371
+ for (const { path } of reference) {
1372
+ if (left.has(path)) continue;
1373
+ const tally = tallyFor(valueMeasureFor(path));
1374
+ tally.total++;
1375
+ tally.unpaired++;
1376
+ if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (reference only)`);
1377
+ }
1378
+
1379
+ return order.map((id) => {
1380
+ const tally = tallyFor(id);
1381
+ const defined = VALUE_MEASURES.find((m) => m.id === id);
1382
+ const missed = tally.total - tally.matched;
1383
+ const note =
1384
+ tally.total === 0
1385
+ ? 'neither side carries one'
1386
+ : missed === 0
1387
+ ? undefined
1388
+ : `${missed} moved` +
1389
+ (tally.unpaired === 0 ? '' : `, ${tally.unpaired} of them on one side only`) +
1390
+ `: ${tally.offenders.join('; ')}` +
1391
+ (missed > tally.offenders.length ? `; …and ${missed - tally.offenders.length} more` : '');
1392
+ return measure(id, defined?.what ?? 'values under a path kind this report does not define', tally.matched, tally.total, note);
1393
+ });
1394
+ }
1395
+
1396
+ /** Every value measure that is not a perfect match, by id. What a test asserts on. */
1397
+ export function movedValueMeasures(measures: DiffMeasure[]): string[] {
1398
+ return measures.filter((m) => m.ratio < 1).map((m) => m.id);
1399
+ }
1400
+
1401
+ /**
1402
+ * `skeleton 1.000 · bones 1.000 · …`, the same one-line shape `reportedFigures`
1403
+ * has, and with no roll-up for the same reason: a mean over "did the times move"
1404
+ * and "did the vertices move" is a number with no referent.
1405
+ */
1406
+ export function valueFigures(measures: DiffMeasure[]): string {
1407
+ return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
1408
+ }
1409
+
1151
1410
  const fmt = (n: number): string => n.toFixed(3);
1152
1411
 
1153
1412
  /**
package/src/validate.ts CHANGED
@@ -3952,3 +3952,271 @@ export function reportLines(report: ValidateReport): string[] {
3952
3952
  export function atlasDirOf(atlasPath: string): string {
3953
3953
  return dirname(resolve(atlasPath));
3954
3954
  }
3955
+
3956
+ // ---------------------------------------------------------------------------
3957
+ // skeletonValues — the round trip read as VALUES rather than as assertions
3958
+ // ---------------------------------------------------------------------------
3959
+
3960
+ /**
3961
+ * One value the parser read out of a skeleton file, at a path that names it.
3962
+ *
3963
+ * `bones/hip/setup/rotation`, `skins/default/head/head/regionUVs/12`,
3964
+ * `animations/walk/RotateTimeline/bone:hip/key/3/v1`. Numbers stay numbers so
3965
+ * that a comparison can state a tolerance; everything else — a blend mode the
3966
+ * runtime holds as an enum, an attachment name, a boolean, an absent object —
3967
+ * is a string, and is compared exactly.
3968
+ */
3969
+ export interface SkeletonValue {
3970
+ /** Stable, name-keyed, and never an index into an emitted array (issue #45). */
3971
+ path: string;
3972
+ value: number | string;
3973
+ }
3974
+
3975
+ /**
3976
+ * Keys the walk below does not read, each with the reason it is not a value the
3977
+ * file carries. It is a SKIP list rather than an include list on purpose: the
3978
+ * field names come from the parser, so a field spine-core starts reading is
3979
+ * compared the day it starts reading it, and the only hand-kept part is the
3980
+ * short list of things that are demonstrably not in the file.
3981
+ *
3982
+ * ⚠️ `id` is the sharp one. `VertexAttachment.id` is a process-wide counter, so
3983
+ * two parses in one process disagree about it by construction — and it reaches
3984
+ * `DeformTimeline.getPropertyIds()`, which is why the timeline key below is
3985
+ * built from the resolved owner rather than from the property ids.
3986
+ */
3987
+ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
3988
+ a: 'the derived world matrix',
3989
+ b: 'the derived world matrix',
3990
+ c: 'the derived world matrix',
3991
+ d: 'the derived world matrix',
3992
+ world: 'derived by the runtime',
3993
+ local: 'derived by the runtime',
3994
+ worldX: 'derived by the runtime',
3995
+ worldY: 'derived by the runtime',
3996
+ region: 'the atlas, which is not the skeleton',
3997
+ regions: 'the atlas, which is not the skeleton',
3998
+ uvs: 'computed from the region',
3999
+ offsets: 'computed from the region',
4000
+ tempColor: 'runtime scratch',
4001
+ deform: 'runtime scratch on the setup pose',
4002
+ id: 'a process-wide counter, not a value in the file',
4003
+ index: 'the array position this walk already iterates by name',
4004
+ timelineIds: 'derived from the timelines',
4005
+ timelineSlots: 'derived from the skin',
4006
+ properties: 'derived from the constraint',
4007
+ propertyIds: 'carries an attachment id, which is a counter (see above)',
4008
+ };
4009
+
4010
+ /** How deep a chain of unnamed objects may go before the walk says so and stops. */
4011
+ const VALUE_WALK_DEPTH = 10;
4012
+
4013
+ /**
4014
+ * Reflection over the parsed form: numbers and strings as they are, arrays by
4015
+ * index, objects by their own keys in **sorted** order, and any nested object
4016
+ * that carries a `name` by that name rather than by expansion — which is what
4017
+ * makes a cross-reference (`parent`, `boneData`, `endSlot`, a constraint's
4018
+ * `bones`) a name and terminates every cycle the runtime's back-references
4019
+ * would otherwise walk forever.
4020
+ *
4021
+ * Sorted rather than insertion-ordered because `A18_DETERMINISTIC_EMIT`'s rule
4022
+ * applies here too: nothing whose order is the runtime's business may decide
4023
+ * what this function emits.
4024
+ */
4025
+ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: number): void {
4026
+ if (value === null || value === undefined) {
4027
+ out.push({ path, value: '(none)' });
4028
+ return;
4029
+ }
4030
+ if (typeof value === 'number' || typeof value === 'string') {
4031
+ out.push({ path, value });
4032
+ return;
4033
+ }
4034
+ if (typeof value === 'boolean') {
4035
+ out.push({ path, value: value ? 'true' : 'false' });
4036
+ return;
4037
+ }
4038
+ if (typeof value === 'function') return;
4039
+ if (ArrayBuffer.isView(value) || Array.isArray(value)) {
4040
+ const list = value as ArrayLike<unknown>;
4041
+ for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${i}`, out, depth + 1);
4042
+ return;
4043
+ }
4044
+ if (typeof value !== 'object') return;
4045
+ const obj = value as Record<string, unknown>;
4046
+ if (depth > 0 && typeof obj.name === 'string') {
4047
+ out.push({ path, value: obj.name });
4048
+ return;
4049
+ }
4050
+ if (depth > VALUE_WALK_DEPTH) {
4051
+ out.push({ path, value: '(deeper than the walk goes)' });
4052
+ return;
4053
+ }
4054
+ for (const key of Object.keys(obj).sort()) {
4055
+ if (key in NOT_A_VALUE_IN_THE_FILE) continue;
4056
+ pushValue(obj[key], `${path}/${key}`, out, depth + 1);
4057
+ }
4058
+ }
4059
+
4060
+ /**
4061
+ * Which timeline this is, in words that both sides of a comparison can reach.
4062
+ *
4063
+ * The class name and the OWNER'S NAME — never the property ids, which carry
4064
+ * array indices and, for a deform timeline, a counter. A rig that reorders its
4065
+ * bones is a structural finding `diff` already makes; it must not also arrive
4066
+ * here as a timeline nobody can pair.
4067
+ */
4068
+ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkeletonData']>): string {
4069
+ const kind = timeline.constructor?.name ?? 'Timeline';
4070
+ const asBone = timeline as Timeline & Partial<{ boneIndex: number }>;
4071
+ if (isBoneTimeline(asBone)) return `${kind}/bone:${data.bones[asBone.boneIndex]?.name ?? `#${asBone.boneIndex}`}`;
4072
+ const asSlot = timeline as Timeline & Partial<{ slotIndex: number }>;
4073
+ if (isSlotTimeline(asSlot)) {
4074
+ const slot = data.slots[asSlot.slotIndex]?.name ?? `#${asSlot.slotIndex}`;
4075
+ const attachment = (timeline as Timeline & Partial<{ attachment: { name: string } }>).attachment;
4076
+ return `${kind}/slot:${slot}${attachment === undefined ? '' : `:${attachment.name}`}`;
4077
+ }
4078
+ const asConstraint = timeline as Timeline & Partial<{ constraintIndex: number }>;
4079
+ if (isConstraintTimeline(asConstraint)) {
4080
+ return `${kind}/constraint:${data.constraints[asConstraint.constraintIndex]?.name ?? `#${asConstraint.constraintIndex}`}`;
4081
+ }
4082
+ return `${kind}/skeleton`;
4083
+ }
4084
+
4085
+ /**
4086
+ * Every value a skeleton file carries, as the **parser** understands it.
4087
+ *
4088
+ * ## Why this is here and not in `diff.ts`
4089
+ *
4090
+ * `diff` compares structure over raw JSON and says so in its own header:
4091
+ * *"Pure JSON reading — no spine-core, no filesystem."* Comparing the values
4092
+ * inside that structure needs the format's per-field defaults — `time` absent
4093
+ * is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 but its `mix`
4094
+ * is 1 — and a second spelling of those inside the gate is exactly what this
4095
+ * repository refuses (CLAUDE.md, *The compiler never invents a value*). So the
4096
+ * defaults are taken from the one reader that owns them, which means the
4097
+ * runtime, which means this file: `CLAUDE.md`'s *Conventions* names the three
4098
+ * modules allowed to link spine-core and `src/validate.ts` is the one that
4099
+ * "owns the round trip". `src/bonedist.ts` is the precedent for the other half
4100
+ * — an instrument that needs the runtime and reaches it through `render.ts`
4101
+ * rather than linking it itself. `diff.ts` compares what this returns.
4102
+ *
4103
+ * ## What it covers
4104
+ *
4105
+ * Everything on `SkeletonData` that is not on the skip list above: the header
4106
+ * and stage, every bone's setup pose and `length`, every slot's colours, blend
4107
+ * and setup attachment, every attachment in every skin (a region's offsets,
4108
+ * rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
4109
+ * triangles, hull and edges; a bounding box's, path's and clipping shape's
4110
+ * vertices), every constraint's pose and flags, every event's payload, and for
4111
+ * every timeline every frame — time and values, from the runtime's own
4112
+ * `getFrameEntries()` — its curve type and Bezier samples, and its deform
4113
+ * vertices, attachment names, draw orders and event payloads.
4114
+ *
4115
+ * ## What it does not
4116
+ *
4117
+ * - **`version` and `hash`.** The rig spec has no field for either; `ingest`
4118
+ * reports them as `HEADER_REDERIVED` and `HEADER_BOOKKEEPING` findings, and
4119
+ * a rebuild restating the runtime rigc links is the whole reason the corpus
4120
+ * gate is `diff` at 1.000 rather than a byte comparison (`docs/INGEST.md`
4121
+ * §2.3). They are named here so that skipping them is a decision a reader
4122
+ * can see rather than an omission.
4123
+ * - **Anything below one float32 step.** `spine-core` stores frames, curves and
4124
+ * vertices in `Float32Array`, so two values that round to the same float32
4125
+ * are equal to this walk whatever the file says. The comparison's tolerance
4126
+ * states that bound rather than hiding it.
4127
+ * - **A Bezier's control points as such.** The parser samples them into
4128
+ * `curves` (`setBezier`), so what is compared is the sampled curve; a moved
4129
+ * handle moves the samples, but by a different amount than it moved the
4130
+ * handle.
4131
+ */
4132
+ export function skeletonValues(skeletonText: string, atlasText: string): SkeletonValue[] {
4133
+ const data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(
4134
+ JSON.parse(skeletonText),
4135
+ );
4136
+ const out: SkeletonValue[] = [];
4137
+ const header = data as unknown as Record<string, unknown>;
4138
+ for (const field of ['x', 'y', 'width', 'height', 'referenceScale', 'fps', 'imagesPath', 'audioPath', 'name']) {
4139
+ pushValue(header[field], `skeleton/${field}`, out, 1);
4140
+ }
4141
+ for (const bone of data.bones) {
4142
+ const at = `bones/${bone.name}`;
4143
+ const rec = bone as unknown as Record<string, unknown>;
4144
+ for (const key of Object.keys(rec).sort()) {
4145
+ if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
4146
+ pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
4147
+ }
4148
+ }
4149
+ for (const slot of data.slots) {
4150
+ const at = `slots/${slot.name}`;
4151
+ const rec = slot as unknown as Record<string, unknown>;
4152
+ for (const key of Object.keys(rec).sort()) {
4153
+ if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
4154
+ pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
4155
+ }
4156
+ }
4157
+ for (const skin of data.skins) {
4158
+ const at = `skins/${skin.name}`;
4159
+ pushValue(skin.color, `${at}/color`, out, 1);
4160
+ pushValue(skin.bones, `${at}/bones`, out, 1);
4161
+ pushValue(skin.constraints, `${at}/constraints`, out, 1);
4162
+ for (const [slotIndex, held] of skin.attachments.entries()) {
4163
+ if (held === undefined || held === null) continue;
4164
+ const slot = data.slots[slotIndex]?.name ?? `#${slotIndex}`;
4165
+ const byName = held as unknown as Record<string, unknown>;
4166
+ for (const placeholder of Object.keys(byName).sort()) {
4167
+ const attachment = byName[placeholder] as Record<string, unknown>;
4168
+ const where = `${at}/${slot}/${placeholder}`;
4169
+ // The attachment's TYPE, which is the one thing reflection cannot see:
4170
+ // a region and a mesh differ in their fields, and a walk that only read
4171
+ // the fields would call a swap a pile of unpaired paths rather than a
4172
+ // kind that changed.
4173
+ pushValue(attachment.constructor?.name ?? '(unknown)', `${where}/kind`, out, 1);
4174
+ pushValue(attachment, where, out, 0);
4175
+ }
4176
+ }
4177
+ }
4178
+ for (const constraint of data.constraints) {
4179
+ const at = `constraints/${constraint.name}`;
4180
+ pushValue(constraint.constructor?.name ?? '(unknown)', `${at}/kind`, out, 1);
4181
+ pushValue(constraint, at, out, 0);
4182
+ }
4183
+ for (const event of data.events) pushValue(event, `events/${event.name}`, out, 0);
4184
+ for (const animation of data.animations) {
4185
+ const at = `animations/${animation.name}`;
4186
+ pushValue(animation.duration, `${at}/duration`, out, 1);
4187
+ // Timelines are grouped by their key and numbered within the group, so that
4188
+ // two timelines a runtime distinguishes by object identity — one deform per
4189
+ // skin over the same slot and attachment — still pair up in file order
4190
+ // rather than colliding on one path.
4191
+ const grouped = new Map<string, Timeline[]>();
4192
+ for (const timeline of animation.timelines) {
4193
+ const key = timelineKey(timeline, data);
4194
+ const held = grouped.get(key);
4195
+ if (held === undefined) grouped.set(key, [timeline]);
4196
+ else held.push(timeline);
4197
+ }
4198
+ for (const key of [...grouped.keys()].sort()) {
4199
+ const group = grouped.get(key) ?? [];
4200
+ for (const [n, timeline] of group.entries()) {
4201
+ const where = `${at}/${key}${group.length > 1 ? `#${n}` : ''}`;
4202
+ // The frames, split into time and values by the runtime's own count of
4203
+ // entries per frame rather than by a table of what each timeline kind
4204
+ // keys. Entry 0 is the time for every timeline spine-core defines.
4205
+ const entries = timeline.getFrameEntries();
4206
+ for (let i = 0; i < timeline.frames.length; i++) {
4207
+ const slot = i % entries;
4208
+ pushValue(timeline.frames[i], `${where}/key/${Math.floor(i / entries)}/${slot === 0 ? 'time' : `v${slot}`}`, out, 1);
4209
+ }
4210
+ const rec = timeline as unknown as Record<string, unknown>;
4211
+ for (const field of Object.keys(rec).sort()) {
4212
+ if (field in NOT_A_VALUE_IN_THE_FILE || field === 'frames') continue;
4213
+ // The owner is in the path already; comparing the index as well would
4214
+ // report one reordering twice, in a measure that is not about order.
4215
+ if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
4216
+ pushValue(rec[field], `${where}/${field}`, out, 1);
4217
+ }
4218
+ }
4219
+ }
4220
+ }
4221
+ return out;
4222
+ }
@@ -9,6 +9,12 @@
9
9
  * emitter defects (#368 `hull`/`edges`, #369 hold curves, #370
10
10
  * `skeleton.images`) before proving that a human edit survives the trip.
11
11
  *
12
+ * ⭐ Every step quotes what its child said when that child did not do what it was
13
+ * for (#541, and step 5 since #621). A skin NEITHER side can draw is a SKIP
14
+ * naming that rather than a red — `check` had nothing to compare, and `validate`
15
+ * and `diff` have already measured the rig — while a skin only ONE side draws is
16
+ * the divergence this whole file exists to find and stays a failure.
17
+ *
12
18
  * It ran from a shell script in a local scratch directory. A tool nobody can
13
19
  * find is not a tool, hence this file.
14
20
  *
@@ -308,6 +314,107 @@ function editorSaid(ran: Ran): string[] {
308
314
  return lines;
309
315
  }
310
316
 
317
+ /**
318
+ * The line a rigc child REFUSED on, out of the stream it printed it on.
319
+ *
320
+ * ⚠️ Not `editorSaid`, which quotes both streams whole. A rigc refusal is a
321
+ * `UsageError` and `cli.ts` prints the entire usage under one — some sixty lines
322
+ * of correct prose that would bury the one sentence somebody needs. Every
323
+ * refusal rigc prints starts, at column zero, with its own name and a colon:
324
+ * `rigc:`, `rigc check error:`, `rigc compile error:`. The usage block has
325
+ * neither shape — its `rigc <command> …` lines are indented, and its unindented
326
+ * ones carry no colon — so none of them is mistaken for one.
327
+ */
328
+ function refusalLines(text: string): string[] {
329
+ return text.split('\n').filter((line) => /^rigc\b[^\n]*:/.test(line));
330
+ }
331
+
332
+ /**
333
+ * What one rigc child said, under the step that ran it.
334
+ *
335
+ * 🚨 Issue #621, and it is `editorSaid`'s defect one surface over. Step 5 was
336
+ * the one step that printed a child's exit code and threw its words away: on a
337
+ * rig whose only attachment is a `boundingbox` it reported a bare `exit=1`, and
338
+ * the renderer's own refusal — *"posed no drawable attachment in any animation
339
+ * or in its setup pose — there is nothing to draw"* — reached nobody. A reader
340
+ * of that log cannot tell a crashed renderer from a rig with no frames, which is
341
+ * the same silence this file already has a judgment about.
342
+ *
343
+ * ⭐ The fallback is the half that keeps the fix from being the defect again one
344
+ * level down: a child that dies with a stack trace prints no `rigc…:` line at
345
+ * all, so when nothing matched, the tail of stderr is quoted rather than
346
+ * nothing.
347
+ */
348
+ function rigcSaid(what: string, ran: Ran): string[] {
349
+ const said = [
350
+ ...refusalLines(ran.stderr),
351
+ // `check` reports its failures on stdout, in the FAIL lines `verdictLines`
352
+ // keeps for the green path.
353
+ ...ran.stdout.split('\n').filter((line) => /^\s*FAIL/.test(line)),
354
+ ];
355
+ if (said.length === 0) {
356
+ said.push(...ran.stderr.replace(/\s+$/, '').split('\n').slice(-6).filter((line) => line !== ''));
357
+ }
358
+ const head = ` ${what} exit=${String(ran.status)}${ran.timedOut ? ' TIMED OUT' : ''}`;
359
+ // Silence is a finding, for the reason `editorSaid` states: "it said nothing"
360
+ // and "this harness threw its words away" look identical from the outside.
361
+ if (said.length === 0) return [head, ' | it printed nothing on stdout or stderr'];
362
+ return [head, ...said.map((line) => ` | ${line.trimEnd()}`)];
363
+ }
364
+
365
+ /**
366
+ * The clause `rigc render` refuses on when the skeleton draws nothing, and the
367
+ * exit code it leaves it with.
368
+ *
369
+ * ⚠️ The clause is the part of that message that does not move: `cli.ts` splices
370
+ * ` under skin "x"` in after "setup pose" when `--skin` was passed, and ends on
371
+ * `— there is nothing to draw` either way.
372
+ */
373
+ const NOTHING_TO_DRAW = 'posed no drawable attachment in any animation or in its setup pose';
374
+
375
+ /** `cli.ts` exits 2 on a `UsageError`, which is what that refusal is. */
376
+ const RIGC_USAGE_EXIT = 2;
377
+
378
+ /**
379
+ * Did this `render` call refuse because there was nothing to draw?
380
+ *
381
+ * 🔒 **Both signals, and the second one is why** (issue #621). The exit code
382
+ * alone is every `UsageError` there is — an unknown flag, a candidate that is
383
+ * not there, a `--skin` the skeleton does not declare — so reading a 2 as
384
+ * "nothing to draw" would turn the export dropping a skin the build declares
385
+ * into a SKIP, which is the one outcome this must never produce. The sentence
386
+ * alone is a string found on a stream however the child exited, including a path
387
+ * that echoed it and then did something else.
388
+ *
389
+ * ⛔ What was rejected: reading the two skeletons here and deciding for
390
+ * ourselves whether either draws. That is a second implementation of
391
+ * `framingViewport` — atlas resolution, the setup pose and every animation — and
392
+ * two answers that need not agree is a checker agreeing with itself. The verdict
393
+ * belongs to the child that refused.
394
+ *
395
+ * ⛔ Also rejected, and it is the more structural signal: an exit code of its own
396
+ * from `cli.ts` for this refusal. `src/render.ts` emits no code at all —
397
+ * `framingViewport` returns null and `cli.ts` turns that into a `UsageError` — so
398
+ * that is a change to rigc's CLI contract rather than to this harness, and it is
399
+ * wider than the card it would be landing under.
400
+ */
401
+ function nothingToDraw(ran: Ran): boolean {
402
+ return !ran.timedOut && ran.status === RIGC_USAGE_EXIT && ran.stderr.includes(NOTHING_TO_DRAW);
403
+ }
404
+
405
+ /**
406
+ * Step 5's verdict when NEITHER side draws (issue #621).
407
+ *
408
+ * The question this step asks is *does what comes back still play the same*. A
409
+ * rig that draws nothing on both sides gives `check` nothing to compare, and the
410
+ * honest answer to a question with no measurement behind it is the one this file
411
+ * gives everywhere else: SKIP by name, never a pass and never a red. `validate`
412
+ * and `diff` have already measured the rig — this is the shape #608 removed from
413
+ * `build`, one tool further out.
414
+ */
415
+ const NOTHING_MEASURED =
416
+ 'neither side draws a frame, so the check is not measured; `diff` and `validate` carry this rig';
417
+
311
418
  function run(cmd: string, args: string[], timeoutS: number): Ran {
312
419
  const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutS * 1000 });
313
420
  return {
@@ -497,6 +604,16 @@ export function skinsDeclaredBy(path: string): string[] {
497
604
  return out;
498
605
  }
499
606
 
607
+ /**
608
+ * How one step-5 block ended (issue #621).
609
+ *
610
+ * Three states rather than an exit code, because `check` not having run and
611
+ * `check` having returned 0 are different facts and used to print the same:
612
+ * `measured` is the only one that carries a figure, and `not measured` is the
613
+ * only one that does not fail the run.
614
+ */
615
+ export type BlockVerdict = 'measured' | 'not measured' | 'no frames';
616
+
500
617
  /** One render-and-check block of step 5: which skin, where its frames go. */
501
618
  export interface SkinBlock {
502
619
  /** The skin this block poses under — `null` when the skeleton declares none. */
@@ -901,21 +1018,63 @@ function main(): void {
901
1018
  // export dropped altogether then reads as a block whose check fails by name,
902
1019
  // where enumerating the export's own skins would quietly stop looking for it.
903
1020
  const blocks = skinBlocks(skinsDeclaredBy(source), opts.out, opts.fps);
904
- const checks: Array<{ skin: string | null; status: number | null; mae: number | null }> = [];
1021
+ const checks: Array<{ skin: string | null; verdict: BlockVerdict; status: number | null; mae: number | null }> = [];
905
1022
  for (const block of blocks) {
906
1023
  emit('');
907
1024
  emit(block.heading);
908
- run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, '--fps', String(opts.fps), ...block.args, '--out', block.buildFrames], 900);
909
- run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, '--fps', String(opts.fps), ...block.args, '--out', block.exportFrames], 900);
1025
+ const args = ['--fps', String(opts.fps), ...block.args];
1026
+ const drawBuild = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, ...args, '--out', block.buildFrames], 900);
1027
+ const drawExport = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, ...args, '--out', block.exportFrames], 900);
1028
+ // 🚨 Both renderers are quoted the moment either did not do what it was for
1029
+ // (issue #621) — the rule steps 1 and 2 have followed since #541, and step 5
1030
+ // was the one step that did not.
1031
+ for (const [what, ran] of [
1032
+ ['render (the build)', drawBuild],
1033
+ ['render (the export)', drawExport],
1034
+ ] as const) {
1035
+ if (ran.status !== 0 || ran.timedOut) for (const line of rigcSaid(what, ran)) emit(line);
1036
+ }
1037
+ const buildBlank = nothingToDraw(drawBuild);
1038
+ const exportBlank = nothingToDraw(drawExport);
1039
+ const buildDrew = drawBuild.status === 0 && !drawBuild.timedOut;
1040
+ const exportDrew = drawExport.status === 0 && !drawExport.timedOut;
1041
+
1042
+ if (buildBlank && exportBlank) {
1043
+ emit(` SKIP ${NOTHING_MEASURED}`);
1044
+ checks.push({ skin: block.skin, verdict: 'not measured', status: null, mae: null });
1045
+ continue;
1046
+ }
1047
+ // 🔒 One side only, and it stays red. "Nothing to draw" is an honest answer
1048
+ // about a RIG; about one side of a round trip it is the loss the trip exists
1049
+ // to find, so the SKIP above is guarded by `&&` and never by `||`.
1050
+ if ((buildBlank && exportDrew) || (exportBlank && buildDrew)) {
1051
+ const blank = buildBlank ? 'the build' : 'the export';
1052
+ const drawn = buildBlank ? 'the export' : 'the build';
1053
+ emit(
1054
+ ` FAIL ${blank} has nothing to draw and ${drawn} draws — one side drawing where the other does not IS ` +
1055
+ 'the divergence this step measures, so it is a failure and never a SKIP',
1056
+ );
1057
+ checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
1058
+ continue;
1059
+ }
1060
+ if (!buildDrew || !exportDrew) {
1061
+ emit(
1062
+ ' FAIL a render did not do what it was for, so there is no frame set to compare — `check` is not run, ' +
1063
+ 'because with a frame set missing its message would name the directory rather than the refusal above',
1064
+ );
1065
+ checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
1066
+ continue;
1067
+ }
910
1068
  const check = run(
911
1069
  rigc.cmd,
912
1070
  [...rigc.prefix, 'check', '--candidate', cand, '--frames', block.buildFrames, ...block.args, '--json', block.checkJson],
913
1071
  900,
914
1072
  );
915
1073
  emit(` exit=${check.status}`);
1074
+ if (check.status !== 0 || check.timedOut) for (const line of rigcSaid('check', check)) emit(line);
916
1075
  for (const line of verdictLines(check.stdout)) emit(` ${line}`);
917
1076
  for (const line of checkFigures(block.checkJson)) emit(` ${line}`);
918
- checks.push({ skin: block.skin, status: check.status, mae: worstMeanMae(block.checkJson) });
1077
+ checks.push({ skin: block.skin, verdict: 'measured', status: check.status, mae: worstMeanMae(block.checkJson) });
919
1078
  }
920
1079
  // The roll-up, so a loss in one skin of many is a line somebody reads rather
921
1080
  // than a row buried in the block above it. The mark is on the LARGEST figure
@@ -923,22 +1082,41 @@ function main(): void {
923
1082
  // against and this tool does not get to invent one, but "these skins did not
924
1083
  // come back the same" is a fact the run itself produced.
925
1084
  if (blocks.length > 1) {
926
- const figures = checks.map((c) => c.mae).filter((v): v is number => v !== null);
1085
+ // ⚠️ Off the MEASURED blocks only (issue #621). A skin nobody could render
1086
+ // has no figure, and folding it in as one would put a number in the column a
1087
+ // reader looks at for a block where nothing was compared.
1088
+ const figures = checks.filter((c) => c.verdict === 'measured').map((c) => c.mae).filter((v): v is number => v !== null);
927
1089
  const worst = figures.length === 0 ? null : Math.max(...figures);
928
1090
  const agreed = figures.length === checks.length && new Set(figures).size === 1;
929
1091
  emit('');
930
1092
  emit(` per skin ${checks.length} block(s)`);
931
- for (const { skin, status, mae } of checks) {
1093
+ for (const { skin, verdict, status, mae } of checks) {
1094
+ // 🔒 Never a pass and never an MAE of 0: a block that measured nothing
1095
+ // says so in the column the figures would have been in.
1096
+ if (verdict !== 'measured') {
1097
+ emit(
1098
+ ` ${String(skin).padEnd(20)} ` +
1099
+ (verdict === 'not measured'
1100
+ ? 'NOT MEASURED — neither side draws a frame under this skin'
1101
+ : '⚠️ NOT MEASURED — a render did not do what it was for, and this skin is red for it'),
1102
+ );
1103
+ continue;
1104
+ }
932
1105
  emit(
933
1106
  ` ${String(skin).padEnd(20)} check exit=${status} worst mean MAE ` +
934
1107
  `${mae === null ? '(no report)' : mae.toFixed(4)}` +
935
1108
  `${status === 0 ? '' : ' ⚠️ this skin did not come back'}` +
936
- `${!agreed && mae !== null && mae === worst ? ` ⚠️ the worst of the ${checks.length} skins` : ''}`,
1109
+ // The mark compares figures, so it needs two of them: crowning the one
1110
+ // skin that WAS measured "the worst" says nothing and reads as a loss.
1111
+ `${!agreed && figures.length > 1 && mae !== null && mae === worst ? ` ⚠️ the worst of the ${figures.length} measured skins` : ''}`,
937
1112
  );
938
1113
  }
939
1114
  if (agreed) emit(` ⤷ every skin came back at the same figure, so no skin is carrying a difference the others are not.`);
940
1115
  }
941
- const checksClean = checks.every((c) => c.status === 0);
1116
+ // 🔒 `not measured` is the one verdict that does not fail the run: nothing was
1117
+ // compared, and a round trip that ends red on a correct rig is the shape #608
1118
+ // removed from `build` (issue #621).
1119
+ const checksClean = checks.every((c) => c.verdict === 'not measured' || (c.verdict === 'measured' && c.status === 0));
942
1120
 
943
1121
  emit('');
944
1122
  emit('## 6 what the editor rewrote');