spine-rigc 2.0.2 → 2.1.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/NOTICE.md CHANGED
@@ -38,8 +38,8 @@ Software's terms, not a licence term of this project:
38
38
  development dependency for its own gate, and so does an install with
39
39
  `spine-core` added beside it. Validation is not optional on either: the gate
40
40
  runs through the runtime where it is installed and through rigc's own validator
41
- where it is not, and the two are held to the same verdicts in CI on 48 of the
42
- 49 assertions — the official parser's own parse runs only where the runtime is.
41
+ where it is not, and the two are held to the same verdicts in CI on 49 of the
42
+ 50 assertions — the official parser's own parse runs only where the runtime is.
43
43
 
44
44
  > **If a product integrates a Spine Runtime to play rigc's output, that integration
45
45
  > is subject to the terms above** — rigc neither creates those terms nor removes
package/README.md CHANGED
@@ -61,7 +61,7 @@ it cuts both ways:
61
61
  is a development dependency: this repository uses it to hold rigc's own validator
62
62
  to the round trip's verdicts — in CI, on the public example recipes and every
63
63
  selftest call, and on a private corpus of production rigs measured before each
64
- release. Of the 49 assertions, 48 run on both; `A00_ROUNDTRIP_PARSE`, the official
64
+ release. Of the 50 assertions, 49 run on both; `A00_ROUNDTRIP_PARSE`, the official
65
65
  parser's own parse, runs only where the runtime is installed and is reported as a
66
66
  SKIP elsewhere. Install it beside rigc (`bun add -d @esotericsoftware/spine-core`)
67
67
  and the same `rigc` runs the round trip on every build. The
@@ -554,7 +554,7 @@ commands take it and what its default is.
554
554
  | Command | Does |
555
555
  | --- | --- |
556
556
  | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained, with `skeleton.images` pointing at it so the editor's import finds the parts |
557
- | `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`, `--padding` and `--page-edges` tune it |
557
+ | `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`, `--padding`, `--page-edges` and `--pack-shape` tune it — `--pack-shape polygon` lets a neighbour sit inside a mesh's rectangle where its hull is not |
558
558
  | `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 |
559
559
  | `validate <dir>` | re-gates artifacts already on disk |
560
560
  | `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. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — 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 |
@@ -577,10 +577,10 @@ and `bun run fetch-examples`. The reasoning behind them is in
577
577
 
578
578
  `build` and `validate` both default to `--profile spine` — the 34 validity rules, which
579
579
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
580
- adds all 49: the other 15 are one renderer's policy and one canvas budget's, and they
580
+ adds all 50: the other 16 are one renderer's policy and one canvas budget's, and they
581
581
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
582
582
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
583
- yourself: author under `--profile spine-html` and read the extra 15 as findings, and
583
+ yourself: author under `--profile spine-html` and read the extra 16 as findings, and
584
584
  gate the release under `--profile spine`.** A `deform` key that folds a mesh inside
585
585
  out is written out under the default and refused by name under `spine-html`, which
586
586
  is the shape of what that split buys you. A report always names
@@ -680,7 +680,9 @@ none of it; a skin only one side declares is a FAIL naming it as **lost** (or
680
680
  **added**) **by the export**, with `diff`'s `attachments.skins` beside it, and is
681
681
  rendered on neither side — and a
682
682
  field-by-field list of what the editor rewrote. Every step quotes what its child
683
- said when that child did not do what it was for, the renderers included; a skin
683
+ said when that child did not do what it was for, the renderers included — the
684
+ editor's words in full and in order, except its `Licensed to:` line, which names
685
+ the licence holder and is replaced by a line saying it was withheld; a skin
684
686
  **neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
685
687
  red, because `check` had nothing to compare and `diff` and `validate` have
686
688
  already measured the rig. One side drawing where the other does not is the
@@ -717,7 +719,7 @@ letting `A17` blame the editor for the harness's own doing.
717
719
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
718
720
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
719
721
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | **the Spine 4.3 format, field by field.** Every key the 4.3 JSON parser and atlas reader take, what each defaults to, and the parser line it comes from — the rows rigc's refusals cite by part number. Ships in the package too |
720
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
722
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 50 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
721
723
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
722
724
  | 🔬 [SURVEY_2026-08-22.md](https://github.com/firejune/rigc/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
723
725
  | 🧬 [GENERATIONS.md](https://github.com/firejune/rigc/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
@@ -767,7 +769,7 @@ quality."* All six, with their verdicts, are in
767
769
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
768
770
 
769
771
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
770
- see, every rung, the run viewer, the 49 assertions and the selftest behind them — is
772
+ see, every rung, the run viewer, the 50 assertions and the selftest behind them — is
771
773
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
772
774
  Live rung status is
773
775
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -221,12 +221,13 @@ What the flags mean:
221
221
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
222
222
  | `--padding` | `build --pack` only: the gutter each region reserves on every side (default `2`), filled by extending the region's own edge pixels outwards. `0` is not a legal-but-tight choice, it is bleed — **§0.1** |
223
223
  | `--page-edges` | `build --pack` only: `pot` (default) or `free`. `pot` keeps both page edges powers of two; `free` sizes the page to the parts — a smaller page, at the cost of region attachments sampling within one least significant bit of the loose build instead of exactly. Any other value is refused by name — **§0.1** |
224
+ | `--pack-shape` | `build --pack` only: `rect` (default) or `polygon`. `rect` keeps every region's cell apart; `polygon` packs a region that only meshes draw by its emitted hull, so a neighbour may sit inside its rectangle where the hull is not, with the padding kept between footprints. A region attachment's footprint stays its rectangle. Any other value is refused by name, and so is the flag without `--pack` — **§0.1** |
224
225
  | `--atlas-in` | `build` and `explain`: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; `build` re-emits the atlas into `--out`, re-anchored, and `explain` writes nothing and poses through it — **§0.2**. On `explain` it is the flag that makes a **size-only** spec readable at all (`ingest --art none`), because posing resolves every attachment against an atlas; without it that pair is refused by name rather than thrown through (§5.1) |
225
226
  | `--images` | where the rig spec's `image` names resolve (overrides the rig's own `images` field, and is relative to your working directory). For `pose` it is the directory of **loose part PNGs to place** — every `.png` in it is a part, in name order. For `chainfit` it is only where each attachment's image name **resolves**: the candidate decides what the parts are, so extra PNGs are unused and a missing name is refused by name (§12.3) |
226
227
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
227
228
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
228
229
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
229
- | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
230
+ | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 50, opt-in |
230
231
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
231
232
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
232
233
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
@@ -294,12 +295,14 @@ unweighted meshes, packed atlases) — reach for it when you are shipping into
294
295
  lists what that profile left out, on `PROF` lines.
295
296
 
296
297
  *Packed atlases* are in that list for what a page may hold, not for being packed:
297
- under `spine-html`, `A06`'s coverage clause is **one part per page OR a tiling
298
- page**, so `--pack --profile spine-html` is an ordinary build — and it is the only
299
- one that puts the renderer's own rulebook over shared-page sampling. What a
300
- *tiling* page has to satisfy under that profile is stated where the clause is,
301
- §5.2's `A06` row: no two regions on one page overlapping. Rotation is refused, and
302
- that is a separate clause about rigc's packer never turning a region.
298
+ under `spine-html` a page is **one part per page OR a tiling page**, so `--pack
299
+ --profile spine-html` is an ordinary build — and it is the only one that puts the
300
+ renderer's own rulebook over shared-page sampling. What a *tiling* page has to
301
+ satisfy under that profile is §5.2's `A49` row: no two regions on one page
302
+ drawing over each other — refused where their rectangles overlap **and** their
303
+ footprints do, a mesh's footprint being its hull and every other region's its
304
+ rectangle. Rotation is refused by `A06`, and that is a separate clause about
305
+ rigc's packer never turning a region.
303
306
 
304
307
  ⚠️ The other half of that clause — every region wholly inside the page it names —
305
308
  is **validity**, so no profile switches it off: a rectangle outside its page is
@@ -314,7 +317,7 @@ atlas. `--pack` is the other arrangement.
314
317
 
315
318
  ```bash
316
319
  bun cli.ts build --rig … --motion … --out spine --pack
317
- # .. pack: skeleton.png 512x1024, 26 region(s), 67.9% covered, padding 2
320
+ # .. pack: skeleton.png 512x1024, 26 region(s), 67.9% covered, padding 2, shape rect
318
321
  # .. validate (packed atlas, pages on disk)
319
322
  ```
320
323
 
@@ -489,7 +492,75 @@ The pixel counts are over 49 frames at `render --max 1024`. **Covered** is the
489
492
  regions' own rectangles over the page, which the pack line prints; **opaque** is
490
493
  the page's texels with any alpha. The gap between the two is transparency
491
494
  inside the parts, which no packer that copies bytes can remove. The pack line
492
- ends `, page edges free` when the flag is set.
495
+ carries `, page edges free` when the flag is set.
496
+
497
+ **`--pack-shape polygon`: rectangles that overlap where nothing is drawn**
498
+ (issue #1099, stage 1 of #1093). A mesh samples only the texels its triangles
499
+ cover, so the part of its rectangle outside its **hull** — the outer loop of
500
+ its triangles, the mesh's first `hull` vertices — can carry a neighbour's
501
+ pixels. Under `polygon` every region whose attachments are all meshes (or
502
+ linked meshes, which draw their source's hull) is packed by that hull as
503
+ `skeleton.json` writes its UVs; a region any region attachment draws keeps its
504
+ rectangle, because a region attachment draws its whole quad. The atlas format
505
+ does not change — every region is still `bounds` + `offsets` + `rotate: 0` —
506
+ what changes is that two rectangles may overlap where neither footprint is.
507
+
508
+ - **The footprint test.** A region owns every texel of its cell whose square,
509
+ grown by `max(--padding, 1)` texels on every side, meets its footprint
510
+ (boundary contact counts); a rectangle owns its whole cell. Two regions may
511
+ be placed when they own no texel in common. Between two rectangles that is
512
+ the `rect` rule exactly, so a rig with no mesh packs byte for byte as it does
513
+ under `rect`; between any two footprints it keeps them at least twice the
514
+ padding apart, as two rectangles are. The set is computed in doubles from the
515
+ UVs and every comparison is made in the footprint's favour by `1e-6` texels,
516
+ so rounding can only keep two footprints further apart, never let them
517
+ touch.
518
+ - **The placement** is the same MaxRects search, ordering and tie-breaks, with
519
+ the free list split by what each region owns instead of by its cell, and a
520
+ candidate is the free rectangle that holds what the cell owns. Every pass is
521
+ also run as the `rect` pass on the same page and the better kept — more parts
522
+ placed, then the higher bottom edge, then `rect` on a tie — so a `polygon`
523
+ page is never larger than the `rect` page. `--page-edges` and the spill rule
524
+ are unchanged, except that the `free` search's area bound sums the texels
525
+ the cells own rather than the cells, since overlapping cells can cover more
526
+ than the page (a spilled `polygon` page was refused as fitting no page until
527
+ it did — `PK86`). What it costs in time is measured on a set shaped like a
528
+ production rig (30 regions of 200 to 900 px, hulls near their rectangles,
529
+ one `free` page about 2023x2046): 1.1–1.4 s against `rect`'s 0.2 s for the
530
+ pack alone, the same page.
531
+ - **The pixels.** Every cell is drawn whole in packing order, then every
532
+ region's owned texels are drawn again with its own values, so every texel a
533
+ region can sample is its own (`PK79`). A texel nobody owns carries the last
534
+ cell drawn over it — which no region samples. So a mesh region's rectangle
535
+ is no longer its own bytes outside its hull, and lifting it back off the page
536
+ (`PK02`, under `rect`) is not a claim this mode makes; a region attachment's
537
+ is, unchanged.
538
+ - **What it costs.** A region that moves on a page samples through a different
539
+ `x / pageWidth`, so its pixels can move by one least significant bit — the
540
+ same bit two `rect` packs of one rig at different positions differ by.
541
+ Measured over the seven gallery rigs (`--pack --page-edges free`, both
542
+ posers, every frame at the protocol rate): five pack identically under both
543
+ shapes and render pixel-identical; `nod` and `squash`, whose packs move, differ
544
+ in 207 of 11,157,504 and 36 of 2,613,248 channel samples, all by exactly 1 —
545
+ against 155 and 44, all by 1, between their own `rect` packs under `pot` and
546
+ `free`. No texel is read from a neighbour (that is three orders of magnitude
547
+ louder, `PK06`).
548
+ - **The gate reads footprints, not rectangles.** Under `--profile spine-html`
549
+ `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` refuses two regions on one page only
550
+ where their rectangles overlap **and** what they draw overlaps — a mesh's
551
+ hull (its UVs over the region's bounds; a linked mesh its source's), a region
552
+ attachment's or an unnamed region's whole rectangle — so a `polygon` pack
553
+ whose rectangles overlap where its hulls do not gates green under both
554
+ profiles, and a neighbour moved inside a hull is named with both regions and
555
+ the page. Touching is not overlapping, as it never was between two
556
+ rectangles. The rule reads nothing about the padding: no file the gate reads
557
+ states it (only the pack line does), and the `2 · padding` the packer keeps
558
+ between footprints is held by `PK81`.
559
+
560
+ The pack line ends `, shape rect` or `, shape polygon` — named under the
561
+ default too, so no reader infers the mode from an absent word, and appended
562
+ after every other field, so a reader that takes the line whole keeps every
563
+ field it read before.
493
564
 
494
565
  ### 0.2 Building against a pack somebody else made — `--atlas-in`
495
566
 
@@ -974,9 +1045,9 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
974
1045
  atlas (`--poser` in the flag table); the refusal is unchanged and still comes
975
1046
  first, so a size-only pair needs `--atlas-in` here whichever reader the survey
976
1047
  takes. ⚠️ `--profile`,
977
- `--pack`, `--page-size`, `--padding`, `--page-edges` and `--copy-images` are
978
- `build`'s and are not here: five of them decide what is *written*, and this
979
- command writes nothing.
1048
+ `--pack`, `--page-size`, `--padding`, `--page-edges`, `--pack-shape` and
1049
+ `--copy-images` are `build`'s and are not here: six of them decide what is
1050
+ *written*, and this command writes nothing.
980
1051
 
981
1052
  📐 **What it will not measure: the texels of a page that is not its declared
982
1053
  size.** Under
@@ -1008,6 +1079,24 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
1008
1079
  [INGEST.md](INGEST.md) §1.3.1, and *The measure inventory* in
1009
1080
  [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md),
1010
1081
  which is repository material rather than part of this package.
1082
+
1083
+ 🔍 **The wiring is compared by name too, not only the rosters** (issue #1085).
1084
+ `constraints.order` reads the order of the one constraint array, which is the
1085
+ order the runtime applies it in; `animations.targets` reads what each timeline
1086
+ keys and `animations.keyed_names` what each key names — an attachment key's
1087
+ attachment, a draw-order offset's slot, an event key's event; `attachments.refs`
1088
+ reads the region an attachment draws, a clipping polygon's `end` and a linked
1089
+ mesh's `source`; `attachments.skin_members` reads what each skin activates.
1090
+ Every one of those, changed alone, poses or draws differently, and every one read
1091
+ 1.000 before. Each note names what differs, candidate first —
1092
+ `candidate only: "heavy|bones|root"; reference only: "heavy|bones|bone"` is a
1093
+ rotate timeline moved from `bone` to `root`, and `attachment
1094
+ "default/clipping/clipping": end "portal-flare6" vs "head-bb"` is a clipping that
1095
+ stops at another slot. A region's `path` is read as the runtime resolves it —
1096
+ `path`, else `name`, else the key — so stating the path an attachment already
1097
+ resolves is no difference. These are name-matched: your own names read low here
1098
+ exactly as they do on `names`, and a low figure with `names` at 1.000 is the
1099
+ wiring that moved.
1011
1100
  - **`check`** renders your candidate into the reference frames' own pixel grid and
1012
1101
  compares pixels — the only thing here that can see a wrong animation. **§9.**
1013
1102
  🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
@@ -5923,13 +6012,13 @@ Fix A00 and run it again.
5923
6012
 
5924
6013
  | Assertion | Profile | What tripped it, and where to fix it |
5925
6014
  | --- | --- | --- |
5926
- | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 49 that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
6015
+ | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 50 that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
5927
6016
  | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | both | a 4.1/4.2-shaped `ik`/`transform`/`path`/`physics`/`slider` array. rigc emits the 4.3 `constraints` array, so this normally means hand-edited JSON Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5928
6017
  | `A02_NO_BONE_TRANSFORM_KEY` | both | `bone "…" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"` — rename it `inherit` in the rig spec Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5929
6018
  | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height`. **SKIP** when the skeleton carries no region attachment |
5930
6019
  | `A04_MESH_TRIANGLES_AND_ENCODING` | both | authored mesh geometry: triangle count not a multiple of 3, an index out of range, or a `vertices` length that disagrees with `uvs` (the weighted/unweighted trap) **SKIP** when the skeleton carries no mesh attachment |
5931
6020
  | `A05_CURVE_ARRAY_LENGTH` | both | a raw `curve` with the wrong number of values, a non-finite number in one, or a curve on a timeline that cannot take one. Four numbers **per value channel**. **SKIP** when no animation carries a timeline at all. Timelines with no `curve` on any key still PASS: every timeline name is checked against the channel table whether or not a curve sits on one Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5932
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND`, or names the dimension when its `IHDR` states a width or height of **0**, which the format forbids (§0.2). **Or** the file reads as a PNG and its **image data cannot be decoded** — its compressed stream is inflated (not unfiltered) and the decoder refuses it, or it inflates short of the rows the header states — and the message names the file and carries the decoder's own words (`the decoder stops with "incorrect data check"`). That clause is validity too: nothing in rigc can read such a page back — and its failure is the **one** line that names such a page: `A19` SKIPs over it and points here rather than naming it again, the way `A17` alone names a missing file. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
6021
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma` and rotation. Two regions on one page over the same texels was a clause here until issue #1099 and is `A49`'s now, read over what the two regions draw rather than over their rectangles. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND`, or names the dimension when its `IHDR` states a width or height of **0**, which the format forbids (§0.2). **Or** the file reads as a PNG and its **image data cannot be decoded** — its compressed stream is inflated (not unfiltered) and the decoder refuses it, or it inflates short of the rows the header states — and the message names the file and carries the decoder's own words (`the decoder stops with "incorrect data check"`). That clause is validity too: nothing in rigc can read such a page back — and its failure is the **one** line that names such a page: `A19` SKIPs over it and points here rather than naming it again, the way `A17` alone names a missing file. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
5933
6022
  | `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file. Three sentences are about blank lines and name three different repairs: `line 1: the file begins with a blank line` (or `with N blank lines` for a run, one finding) is a blank before the first page name — delete it; `line N: the file ends with a blank line` (or `with N blank lines`, one finding) is a blank after the last page block's final line — delete it, since rigc ends the file on one newline; `line N: consecutive blank lines` is two blank lines side by side between the first page and the last — delete one. None of the three is `the last page block declares no region`, which means a page name and its fields with no region after them. rigc writes none of the three: under `--atlas-in` the pack's blank lines are put in rigc's shape on re-emission (§0.2), so any of them means a file rigc did not write, handed to `validate`. ⚠️ An atlas with **no page block at all** — no non-blank line — is not one of those: its subject is absent, so this reports **SKIP** naming the byte count it read, and so do the four rules below whose subject is a page ([#608](https://github.com/firejune/rigc/issues/608)). A rig whose skins need no art writes exactly that file (§3.4), and before #608 this row refused it with two findings naming a page block that was not there. What an empty atlas does **not** excuse is an attachment that wants a region out of it — that is `A08` Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5934
6023
  | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both | three things, and the message says which: an attachment whose `path` names **no region** of this atlas; a `path` carrying **stray whitespace**, printed quoted so you can see it; an **atlas region name** carrying stray whitespace (`A07` names that same line with its line number). The first two are read off the raw file **before** the loader is asked, so the miss is named here with the skin, the slot, the placeholder and the attachment's own name — the four things `AtlasAttachmentLoader`'s own `Region not found in atlas: <path> (attachment: <name>)` does not carry. There is no `spine-html` clause here — a placeholder is free to differ from the region its `path` names **SKIP** when no attachment names a region *and* the atlas declares none — both of its subjects at once |
5935
6024
  | `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration ≠ the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule §4.5 states at compile time — held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all — a static rig has no duration |
@@ -5972,6 +6061,7 @@ Fix A00 and run it again.
5972
6061
  | `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13. **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode, and a `hold` key to its own time as the runtime stores it, a 32-bit float — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
5973
6062
  | `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | an ik constraint resting at `mix` 0 that no animation keys **away from 0** (§4.9, §4.12). `IkConstraint.update` returns on `mix === 0`, so it sits in the update cache and moves nothing. The keys are read the way `A23`/`A36`/`A37` read theirs — every value the loaded timeline poses on its `mix` channel, Bezier samples included — so a timeline keying 0 only is no rescue: [measured] it poses every bone exactly where the same rig with no constraint does. Live is the runtime's `!== 0`, so a negative mix is not refused. `ik constraint "C" has mix 0 at setup and none of the 1 animation keys its mix above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "ik", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused: `ik constraint "C" is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — … — so the declaration exempts nothing; drop the entry`. **SKIP** when the skeleton declares no ik constraint, and **SKIP by name** when every ik constraint it declares is declared consumer-driven: `every ik constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the rule measures that one and the stats line names the declared (`ikConsumerDriven`) |
5974
6063
  | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | a transform constraint none of whose mixes **for a property it drives** is away from 0 at setup or on any value an animation poses (§4.10, §4.12), or one whose `properties` name no `to` at all. A property is applied only when its own mix `!== 0`, and a key that omits a mix reads it as 1, so the six-mix early return of `TransformConstraint.update` would take a key of `mixRotate: 0` alone as a rescue — [measured] that key, and one keying `mixX` 1 on a rotate-only constraint, pose every bone exactly where no constraint does. A negative mix runs, and five transforms in the editor's example exports rest at −1. `transform constraint "C" drives rotate and has mixRotate 0 at setup, and none of the 1 animation keys its mix above 0; a mix is read only for a property the constraint drives, and update() skips each one at 0, so nothing ever moves "follower" — rest mixRotate above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "transform", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused as `A47`'s is; one that drives no property is refused with its own sentence declared or not, since no mix it carries is read by anybody. **SKIP** when the skeleton declares no transform constraint, and **SKIP by name** when every transform constraint it declares is declared consumer-driven: `every transform constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the stats line names the declared (`transformConsumerDriven`) |
6064
+ | `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` | renderer | two regions on one page whose rectangles overlap **and** whose footprints overlap — so one drawing samples texels that are the other's. A region's **footprint** is what samples it: a region a mesh names (its `path`, else its name, every frame of a `sequence` included) draws the mesh's hull loop and its triangles, its UVs placed on the page as `MeshAttachment.computeUVs` places them (a **linked mesh** draws its source's); a region a region attachment names, or that nothing names, draws its whole rectangle — and so does a mesh region whose UVs leave 0..1, whose hull or triangles do not index its vertices, or that is turned by `rotate:`, and every region when a `sequence` cannot be read. For two rectangles that is exactly the clause `A06` held until issue #1099 — an editor alias, a sequence frame, two rectangles moved over the same texels are refused as they were — and it accepts what `--pack-shape polygon` writes: a neighbour inside a mesh's rectangle where the hull is not (§0.1). **Touching is not overlapping**: interiors are compared, every comparison `FOOTPRINT_SLACK` (1e-6 texels) in favour of apart. `regions "ear_l" (341,696 112x420) and "mouth" (332,709 96x48) overlap on page "skeleton.png", and so do their footprints — "ear_l" draws the hull of mesh "ear_l" (skin "default", slot "ear_l"), "mouth" draws its whole rectangle, which region attachment "mouth" (skin "default", slot "mouth") draws; what they draw overlaps by 1916.5512 texel(s) of area inside 347,709 81x48. One drawing samples texels that are the other's: move one region clear of the other's footprint, or pack the page again` — `gallery/nod`'s polygon pack with `mouth` moved onto a triangle of the `ear_l` mesh (`PK88`). Nothing about the **padding** is read — the atlas and the model document do not state it — and the `2 · padding` the packer keeps is the packer's own (`PK81`). A renderer rule because two regions over the same texels is what an editor's packer writes for an alias or a sequence: 49 pairs on four of the ten atlases in `examples/`. **SKIP** when the atlas declares no page or no region, and when no page carries two regions — every unpacked build |
5975
6065
 
5976
6066
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
5977
6067
  clauses are gated by profile.
package/docs/FACE.md CHANGED
@@ -1824,7 +1824,7 @@ it come off:
1824
1824
  MESH head authored 25 vertices / 32 triangles (budget 32) bones=[head] attachments=[head] covers 100.00% of the art, reaching 95.90px past it
1825
1825
  MESH hair_bang authored 15 vertices / 16 triangles (budget 32) bones=[bang] attachments=[hair_bang] covers 100.00% of the art, reaching 55.22px past it
1826
1826
  .. validate (spine-core round trip + machine assertions, profile spine)
1827
- .. profile spine — 7 renderer-policy and 8 archetype assertion(s) do not apply
1827
+ .. profile spine — 8 renderer-policy and 8 archetype assertion(s) do not apply
1828
1828
  ```
1829
1829
 
1830
1830
  `spine` is the default, so that is the `build` above with no `--profile` on it —
package/docs/INGEST.md CHANGED
@@ -64,7 +64,7 @@ an upstream `license.txt` (Appendix, and [NOTICE.md](../NOTICE.md)).
64
64
  | **`preview --candidate <skeleton.json>`** | ✅ **yes** | one self-contained `.html` that plays it in the official Spine Web Player. Needs a network the first time it is opened ([NOTICE.md](../NOTICE.md)) |
65
65
  | **`vote --candidate <a> --candidate <b>`** | ✅ **yes, on either side** | a ballot page. Pairing a foreign export against your own transcription is a legitimate ballot, and the panes carry no paths |
66
66
  | **`check --candidate <skeleton.json> --frames <dir>`** | ✅ **yes** | ⭐ it reads **frames and never a reference skeleton**, so a foreign export enters this one *twice over*: as the candidate, or — via `render` — as the source of the frames. §1.4 |
67
- | **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 49 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
67
+ | **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 54 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
68
68
  | **`ingest <skeleton.json> --out <dir>`** | ✅ **yes — and it is the only reader that WRITES specs** | the `.json` alone; no atlas, no art, no project file. Out come `rig.json`, `motion.json` and a findings report, such that `build`ing them reproduces the skeleton it read **byte for byte — for a skeleton rigc emitted**. ⚠️ For an editor export the claim is identity in canonical form apart from `hash` and `spine`, which §2.3 states in full. The seventh reader, and the one that ends §2's hand work — §2.0 and §5 |
69
69
  | **`pose --images <dir> --frame <png>`** | ⛔ **not the skeleton** | loose part PNGs and one picture. A packed atlas page is not loose parts, and pointing it at one produces a confident answer about nothing — §5 |
70
70
  | **`explain --rig … --motion … --out …`** | ⛔ **no** | rig spec + motion spec. It explains **what you wrote**, which makes it a transcription instrument rather than a reading one — §1.5 |
@@ -137,7 +137,7 @@ rigc validate examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json
137
137
  ```
138
138
  rigc validate …/examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json
139
139
  .. atlas …/examples/3-timing-and-spacing/export/3-timing-and-spacing.atlas
140
- .. profile spine — 7 renderer-policy and 8 archetype assertion(s) do not apply
140
+ .. profile spine — 8 renderer-policy and 8 archetype assertion(s) do not apply
141
141
  PASS A07_ATLAS_TEXT_SHAPE
142
142
  PASS A00_ROUNDTRIP_PARSE
143
143
  PASS A16_SKELETON_VERSION_4_3
@@ -215,7 +215,7 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
215
215
 
216
216
  ### 1.3 `diff` — and the two things it cannot see
217
217
 
218
- `diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
218
+ `diff` takes two compiled skeletons and reports 54 measures in eight groups, plus two
219
219
  blocks that report and gate nothing: the `(reported)` measures beside `attachments`
220
220
  and `animations`, and the `skeleton` header block at the top, which measures the stage.
221
221
  A ninth group of six joins them when something has paired the two sides'
@@ -250,7 +250,7 @@ rigc diff
250
250
 
251
251
  bones (name-agnostic) mean 1.000 over 5 measures — the same two skeletons compared with names thrown away
252
252
  …
253
- animations mean 1.000 over 9 measures
253
+ animations mean 1.000 over 11 measures
254
254
  1.000 count 2/2 how many animations
255
255
  1.000 names 2/2 the animation names
256
256
  1.000 duration 2/2 each animation runs as long (last key time, within one frame)
@@ -267,7 +267,7 @@ it does not say *which* side has three. The `..` line above is where you read th
267
267
  ⛔ **`diff` is blind to every coordinate a bone, an attachment or a key carries.** No
268
268
  measure reads a bone's `x`/`y`/`rotation`, an attachment's offset, or a key's value —
269
269
  only *presence*, *names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5
270
- units and every one of the 49 measures still reads **1.000**. ⇒ Never take a green
270
+ units and every one of the 54 measures still reads **1.000**. ⇒ Never take a green
271
271
  `diff` as evidence that a geometric edit did not land, and never take it as evidence
272
272
  that one did.
273
273
 
@@ -325,7 +325,7 @@ two sections, the second over the paired shots — `duration`, `timeline_kinds`,
325
325
  it used:
326
326
 
327
327
  ```
328
- animations mean 0.222 over 9 measures
328
+ animations mean 0.182 over 11 measures
329
329
  1.000 count 1/1 how many animations
330
330
  0.000 names 0/2 the animation names
331
331
  …
@@ -475,8 +475,8 @@ no field of the file).
475
475
  📊 **For an editor export the claim is weaker, and measured.** Every
476
476
  `examples/*/export/*.json` ingested with `--art none`, rebuilt through the pack
477
477
  beside it, and `diff`ed against the file it was read from: **12 of 12 come back with 0
478
- blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones** — and on
479
- all **nine value measures** too, over **193,927** compared values. Byte
478
+ blockers and 1.000 on all 54 ratio-bearing measures and all 6 reported ones** — and on
479
+ all **nine value measures** too, over **195,363** compared values. Byte
480
480
  identity is not the claim there and the reason is the input, not the round trip — §2.3
481
481
  has the pass line, and what the value measures do and do not reach. ⚠️ Which pack is "the one beside it" is
482
482
  resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, and
@@ -702,7 +702,7 @@ State the ambition in the right units, because three different things get called
702
702
 
703
703
  | Ambition | Reachable? | What it costs, and what it proves |
704
704
  | --- | --- | --- |
705
- | **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 49 measures**. Aim here first |
705
+ | **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 54 measures**. Aim here first |
706
706
  | **Geometric agreement** — the same drawn pixels, allowing for the atlas | ✅ yes, and `check` measures it | see below |
707
707
  | **Byte-identical JSON** | ✅ **for a rebuild, in canonical form, apart from `hash` and `spine`** — the pass line below | a **rebuild** of an editor export (`ingest`, then `build`) is the export. A **transcription** by hand is not held to it: what differs there is what a person chose to write, not the emitter |
708
708
 
@@ -745,7 +745,7 @@ keys**, which is why a moved value is invisible to it.
745
745
  ⭐ **The values are measured by a second comparison rather than by `diff`.**
746
746
  Structure at 1.000 is silent about the numbers inside it: a decompiler that halved
747
747
  every rotation, dropped every bone's `length` or mirrored every vertex would read
748
- 1.000 on all 49 measures and on every `(reported)` one. So the example round trip is
748
+ 1.000 on all 54 measures and on every `(reported)` one. So the example round trip is
749
749
  also compared **value by value**,
750
750
  with the format's defaults taken from the parser rather than from a table — both files
751
751
  are read through `spine-core` and the parsed forms are compared path by path, under a
@@ -755,12 +755,12 @@ storage. Nine measures — here is the `6-arcs` export's, wrapped to fit this
755
755
  page:
756
756
 
757
757
  ```
758
- values: 9/9 measure(s) at 1.000 over 13865 compared value(s); skeleton 1.000 ·
758
+ values: 9/9 measure(s) at 1.000 over 13977 compared value(s); skeleton 1.000 ·
759
759
  bones 1.000 · slots 1.000 · attachments 1.000 · constraints 1.000 · events 1.000 ·
760
760
  key_times 1.000 · key_values 1.000 · curves 1.000
761
761
  ```
762
762
 
763
- Over the twelve exports that is **193,927 values** compared, and all twelve read
763
+ Over the twelve exports that is **195,363 values** compared, and all twelve read
764
764
  1.000 on all nine.
765
765
 
766
766
  `docs/BENCHMARK.md`'s *The nine value measures* is the full statement. What it still
@@ -948,24 +948,24 @@ prints a `CONSUMER_DRIVEN_MIX` judgement (§2.0), and `build` then SKIPs it by n
948
948
  runtime is content and the burden is on the rule. Reporting that is a better answer
949
949
  than a quietly edited export.
950
950
 
951
- ### 3.3 Profile choice, and the fifteen rules that will not fire
951
+ ### 3.3 Profile choice, and the sixteen rules that will not fire
952
952
 
953
953
  `--profile spine` is the default and answers *"is this valid Spine 4.3 that any
954
954
  runtime plays correctly?"* — and it is the right profile for foreign data, because the
955
955
  other one is this project's own renderer and archetype policy.
956
956
 
957
- **Fifteen assertions do not run under `spine`, and they come back `PROF`, not
957
+ **Sixteen assertions do not run under `spine`, and they come back `PROF`, not
958
958
  `SKIP`:**
959
959
 
960
960
  | Excluded as | Rules |
961
961
  | --- | --- |
962
- | **renderer policy** (7) | `A11_NO_CLIPPING_ATTACHMENTS`, `A12_NO_DARK_COLOR`, `A13_MESH_BUDGET`, `A14_NO_FULL_FRAME_MESH`, `A15_IDLE_NO_MESH_BONE_KEYS`, `A19_OVERLAY_PNGS_HAVE_ALPHA`, `A27_REGION_NAME_MATCHES_PAGE_FILENAME` |
962
+ | **renderer policy** (8) | `A11_NO_CLIPPING_ATTACHMENTS`, `A12_NO_DARK_COLOR`, `A13_MESH_BUDGET`, `A14_NO_FULL_FRAME_MESH`, `A15_IDLE_NO_MESH_BONE_KEYS`, `A19_OVERLAY_PNGS_HAVE_ALPHA`, `A27_REGION_NAME_MATCHES_PAGE_FILENAME`, `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` |
963
963
  | **archetype policy** (8) | `A21_MESH_RIM_PINNED`, `A24_AXIS_SPACE_STROKE`, `A25_DETACHED_BONE_PARENTAGE`, `A26_SLOT_DRAW_ORDER`, `A28_RIBBON_ROWS_SHARE_WEIGHTS`, `A29_STROKE_WITHIN_CONTACT_DEPTH`, `A30_STROKE_WITHIN_CAP_CONTAINMENT`, `A39_DEFORM_KEEPS_TRIANGLE_WINDING` |
964
964
 
965
965
  Two further rules — **`A06`** and **`A20`** — are *mixed*: their validity clauses run
966
966
  in both profiles and their policy clauses only under `spine-html`. `A06`'s
967
- size-vs-PNG check is validity; one-part-per-page coverage, rotation and premultiplied
968
- alpha are policy. `A20`'s weight coherence is validity; requiring a mesh to be
967
+ size-vs-PNG check is validity; rotation and premultiplied alpha are policy (two
968
+ regions over the same texels was a policy clause of `A06` until it became `A49`). `A20`'s weight coherence is validity; requiring a mesh to be
969
969
  weighted at all is policy.
970
970
 
971
971
  ⚠️ **`--profile spine-html` on foreign data produces a wall of failures that mean
@@ -1082,7 +1082,7 @@ same displacement evaluated across the bone's own rotation:
1082
1082
  **What the instruments say about it** — and this pair is why §1.3 carries its warning:
1083
1083
 
1084
1084
  - **`diff` sees nothing.** `rigc diff work/t3b/skeleton.json <the export>` reads
1085
- **1.000 on all 49 measures**: same bones, names, parents, order, slots, draw order,
1085
+ **1.000 on all 54 measures**: same bones, names, parents, order, slots, draw order,
1086
1086
  attachments, animations, timelines, key counts, curve kinds. All true, and all silent
1087
1087
  about a 236.5-unit move.
1088
1088
  - **`check` sees it loudly**, with the framing pinned so the comparison is of pictures
@@ -1192,28 +1192,35 @@ slots to `arm-art`/`block-art` and their attachments to match:
1192
1192
  1.000 bone_binding_shape 2/2 as many slots hang off a bone of each shape (`?` = no such bone is declared)
1193
1193
  1.000 order_shape 2/2 the draw order is the same order of `<attachment type>@<bone shape>`
1194
1194
 
1195
- attachments mean 0.889 over 9 measures
1195
+ attachments mean 0.818 over 11 measures
1196
1196
  1.000 skins 1/1 the skin names
1197
1197
  1.000 count 2/2 how many attachments
1198
1198
  0.000 names 0/4 skin/slot/attachment keys
1199
1199
  1.000 type_counts 2/2 as many of each attachment type
1200
1200
  …
1201
- animations mean 1.000 over 9 measures
1201
+ 0.000 refs 0/2 each attachment resolves the same region, clipping end and linked-mesh source — 2 on one side only, which `attachments.names` names
1202
+ 1.000 skin_members 1/1 each skin activates the same skin-required bones and constraints
1203
+ …
1204
+ animations mean 0.909 over 11 measures
1205
+ …
1206
+ 0.000 targets 0/8 each timeline keys the same bone, slot, constraint or attachment — …
1202
1207
  ```
1203
1208
 
1204
1209
  Three things to read out of that, in order:
1205
1210
 
1206
1211
  - **The name-keyed collapse is the task, not a defect.** `bones` 0.567, `slots` 0.143,
1207
- `attachments` 0.889. Note that several *non*-name measures fall with them —
1212
+ `attachments` 0.818, `animations` 0.909. Note that several *non*-name measures fall with them —
1208
1213
  `slots.bone`, `slots.blend`, `bones.parent_by_name` — because they are keyed **by**
1209
1214
  the name that changed. They are not saying the binding changed.
1210
1215
  - 🚨 **`bones (name-agnostic)` and `slots (name-agnostic)` must stay 1.000.** They
1211
1216
  measure the rig with the vocabulary thrown away, so a rename that changed only names
1212
1217
  leaves them untouched. **A drop there is a structural mistake wearing a rename's
1213
1218
  clothes**, and it is the only assertion this recipe really has.
1214
- - **`animations` stays 1.000** because animation *names* were not part of the ask.
1215
- Under the same edit `bones.names` reads `1/5` — `root` survived, and the total is the
1216
- union of both vocabularies.
1219
+ - **`animations` moves on one measure only, `targets`**, because animation *names*
1220
+ were not part of the ask but every timeline keys a renamed bone — `targets` is keyed
1221
+ by the name that changed, as `slots.bone` is. Every other `animations` measure stays
1222
+ 1.000. Under the same edit `bones.names` reads `1/5` — `root` survived, and the total
1223
+ is the union of both vocabularies.
1217
1224
 
1218
1225
  ⛔ And remember §1.3: `diff` reads no coordinates either way. A rename that also moved
1219
1226
  something is invisible to every measure in that report. Pair it with a `check` against
@@ -1255,7 +1262,7 @@ that already round-trips.
1255
1262
  4. **Read `diff` knowing what it is about to say.**
1256
1263
 
1257
1264
  ```
1258
- animations mean 0.793 over 9 measures
1265
+ animations mean 0.821 over 11 measures
1259
1266
  0.667 count 2/3 how many animations
1260
1267
  0.667 names 2/3 the animation names
1261
1268
  0.667 duration 2/3 each animation runs as long (last key time, within one frame)
@@ -1265,10 +1272,12 @@ that already round-trips.
1265
1272
  1.000 event_keys 0/0 as many event firings — neither side has any
1266
1273
  0.667 draw_order 2/3 a draw-order timeline is present or absent alike
1267
1274
  0.667 deform 2/3 a deform timeline is present or absent alike
1275
+ 0.889 targets 8/9 each timeline keys the same bone, slot, constraint or attachment — …
1276
+ 1.000 keyed_names 0/0 each key names the same attachment, draw-order slot or event — neither side has any
1268
1277
  ```
1269
1278
 
1270
1279
  🚨 **Every one of those got worse, and that is the correct result.** The
1271
- `animations` section went 1.000 → 0.793 because the candidate now has something the
1280
+ `animations` section went 1.000 → 0.821 because the candidate now has something the
1272
1281
  reference does not. `diff` measures agreement with a reference; you were asked to
1273
1282
  *disagree* with it, in one specific way. ⇒ **Check that the drop is confined to the
1274
1283
  `animations` section and is the size the addition explains** — one animation of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {