spine-rigc 0.22.2 → 0.24.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/docs/AUTHORING.md CHANGED
@@ -329,28 +329,129 @@ Four things are refused rather than warned about, because each of them otherwise
329
329
  | a page the atlas names and the disk lacks | nothing to sample; caught on the way in, so the message names the atlas rather than the artifact rigc wrote from it |
330
330
  | a rectangle that runs off its page | `x + width` past the page width makes `u2 > 1`, which samples whatever the wrap mode does |
331
331
 
332
- Two limits, stated rather than discovered:
332
+ One limit, stated rather than discovered:
333
333
 
334
334
  - an **optional state** (a manifest `states:` entry) whose region is not in the
335
335
  pack is a `DROP`, not a refusal — the same documented absence a missing PNG has
336
- always been, and the line names the atlas rather than a file nobody opened;
337
- - a generator that **measures a part's pixels** — the `contour` mesh — lifts the
338
- drawing back off the page, and refuses by name on a region packed `rotate: 90`.
339
- Supply the loose PNG for that part instead. rigc's own packs never rotate, so
340
- only a foreign pack reaches it.
336
+ always been, and the line names the atlas rather than a file nobody opened.
337
+
338
+ A **turned** region is not one of them, and used to be (issue #570). A pack made
339
+ by somebody else routinely rotates a region to save space — `rotate: 90`,
340
+ `rotate: 180`, `rotate: 270`, or the format's older `rotate: true` — and rigc
341
+ reads all of them: anything that measures a part's pixels sees the same grid it
342
+ would have seen from the loose PNG, so a `contour` mesh traces the same
343
+ silhouette and an authored mesh's fit figure is the same number. The one thing
344
+ that does not change is what rigc **writes**: its own packer never turns a
345
+ region.
346
+
347
+ ⚠️ Two limits here are real and neither is about rotation:
348
+
349
+ - `--atlas-in` cannot recover what a `scale:` quantised away (above), turned or not;
350
+ - the **renderer** profile still refuses a turned region outright
351
+ (`A06`, `--profile spine-html`), because that profile is about artifacts rigc
352
+ itself emits and it never packs one turned. Reading a foreign pack and gating
353
+ one under somebody else's renderer policy are different questions.
341
354
 
342
355
  `--pack` and `--atlas-in` are opposite directions through the same door and are
343
356
  refused together.
344
357
 
358
+ ### 0.3 Starting from a skeleton somebody else made — `ingest`
359
+
360
+ Everything above starts from two spec files you wrote. `rigc ingest` starts from a
361
+ **Spine 4.3 `skeleton.json` you were handed** and writes those two files for you, so
362
+ an existing rig is a starting point instead of 250 KB of arrays to retype.
363
+
364
+ ```bash
365
+ bun cli.ts ingest hero.json --out specs/ --stage 0,0,1024,768 --images parts/
366
+ # .. out /abs/path/specs
367
+ # .. art loose
368
+ # .. images ../parts/ (the rig spec's own, from /abs/path/specs)
369
+ # JUDGE DURATION: animation "idle" — skeleton JSON carries no duration; the largest key time (2.667) is used …
370
+ # LOSS PATH_LENGTHS: skin "default" slot "track" attachment "track" — the source states `lengths`; rigc RE-MEASURES it …
371
+ # rigc: wrote /abs/path/specs/rig.json
372
+ # rigc: wrote /abs/path/specs/motion.json
373
+ # rigc: wrote /abs/path/specs/findings.json
374
+ # rigc: build it with rigc build --rig /abs/path/specs/rig.json --motion /abs/path/specs/motion.json --out <dir>
375
+
376
+ bun cli.ts build --rig specs/rig.json --motion specs/motion.json --out spine
377
+ bun cli.ts diff spine/skeleton.json hero.json # 1.000 on every measure
378
+ ```
379
+
380
+ **The loop it belongs in is the one above, with a different first step.** `ingest`
381
+ writes the specs; you *edit* them the way you would edit specs you wrote; `build`
382
+ gates; `explain`, `render`, `preview` and `check` read the result. Nothing
383
+ downstream knows or cares that the file started somewhere else — which is the point,
384
+ and also the hazard the `note` below exists for.
385
+
386
+ **The contract is an equality, not a rulebook.** `build(ingest(x))` is `x`: the
387
+ rebuilt `skeleton.json` is byte for byte the file `ingest` read, and the rebuilt
388
+ atlas holds the same **region blocks** — as a multiset, because the order the pages
389
+ come out in is in no field of the skeleton and a decompiled spec cannot know it.
390
+ That is a gate rather than a claim: `bun run selftest` round-trips every rig this
391
+ repository builds on every run.
392
+
393
+ **The flags are for what a skeleton does not encode**, and nothing else is a flag.
394
+
395
+ | flag | what it decides |
396
+ | --- | --- |
397
+ | `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
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
+ | `--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 |
401
+ | `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
402
+
403
+ ⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
404
+ *overrides* the rig spec's own directory for one invocation; `ingest --images`
405
+ *writes* it, once, so no invocation needs the override. They share a name because
406
+ they name the same field — and `ingest` spells the value with the same function
407
+ `build` spells `skeleton.images` with, so a spec and the skeleton it came from say
408
+ where the parts are in one convention.
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.
417
+
418
+ ⚠️ **The duration is a convention, and it is recorded as one.** Skeleton JSON has no
419
+ duration field. The largest key time is the only derivable answer and it is what a
420
+ runtime plays to — but it is wrong for an animation that holds its last pose past its
421
+ last key, and nothing in the file can tell the two apart. `ingest` writes the largest
422
+ key time, states the convention in the motion spec's `note`, and records a finding per
423
+ animation. If you know the real number, edit it: the declared duration is checked
424
+ against the compiled keys, so an honest one costs you nothing.
425
+
426
+ **Read the findings; they are the product.** Three kinds, and the exit code turns on
427
+ the first:
428
+
429
+ | gutter | meaning |
430
+ | --- | --- |
431
+ | `BLOCK` | the spec format cannot say it, so the rebuild will **not** be the file that was read — `linkedmesh`, `point`, an attachment `sequence`, an unknown field on a bone, slot or constraint, a timeline family the motion spec has no track for. The command exits non-zero **and still writes both specs**, because a spec plus a list of what is missing from it beats no spec |
432
+ | `JUDGE` | the skeleton cannot answer and somebody has to: the stage, and each animation's duration |
433
+ | `LOSS` | the skeleton says it and rigc re-derives it, on purpose. A path attachment's `lengths` is the one that matters — it is `PathConstraint`'s own four-sample measurement rather than an arc length (#560), so a transcribed one would freeze whatever produced the source |
434
+
435
+ 📝 **Do not delete the `note`.** Both written specs carry one saying the file is
436
+ decompiled and naming the skeleton it came from. A decompiled spec is
437
+ indistinguishable from an authored one by inspection, every gate here calls it green —
438
+ because it *is* green — and no gate can catch the note's absence. It carries no
439
+ timestamp, deliberately: `A18_DETERMINISTIC_EMIT` compares two independent compiles
440
+ byte for byte, and a dated note would break the first rebuild from the spec.
441
+
442
+ ⛔ **It reads skeleton JSON and nothing else** — not a `.spine` project, not a binary
443
+ `.skel`, not the atlas, not the art. A path that is not a `.json` is refused by name.
444
+ [INGEST.md](INGEST.md) is the whole page on working from a file you were handed.
445
+
345
446
  The other commands:
346
447
 
347
448
  ```bash
348
449
  bun cli.ts explain --rig … --motion … --out … # the compiled rig as a table
349
450
  bun cli.ts validate path/to/spine # re-gate artifacts already on disk
350
451
  bun cli.ts diff candidate.json reference.json
351
- bun cli.ts check --candidate path/to/spine --frames path/to/frames
452
+ bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin …]
352
453
  bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
353
- bun cli.ts render --candidate path/to/spine [--animation …] [--fps 12] [--max 256]
454
+ bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
354
455
  bun cli.ts preview --candidate path/to/spine [--animation …] [--out preview.html]
355
456
  bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
356
457
  bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
@@ -372,6 +473,9 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
372
473
  for opposite fixes. A measure with nothing to compare says `0/0` and says so.
373
474
  - **`check`** renders your candidate into the reference frames' own pixel grid and
374
475
  compares pixels — the only thing here that can see a wrong animation. **§9.**
476
+ 🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
477
+ on a rig with named skins, a run with no skin draws none of their art on either
478
+ side and reports a perfect `0.0000` about it (**§9**).
375
479
  - **`bench <rung>`** runs one rung of [the benchmark ladder](https://github.com/firejune/rigc/blob/main/docs/LADDER.md): validate
376
480
  under `--profile spine`, then diff against that rung's reference export, and with
377
481
  `--frames` the `check` table as well. Unlike the three above it is a **finish
@@ -673,8 +777,8 @@ is recorded in `bench/runs/README.md`, *What a run may read*.)
673
777
 
674
778
  | Field | Spine meaning | Default |
675
779
  | --- | --- | --- |
676
- | `x`, `y` | setup-pose bounding box origin | `0` |
677
- | `width`, `height` | setup-pose bounding box size | falls back to the manifest's crop; **with neither, the compile fails** |
780
+ | `x`, `y` | setup-pose bounding box origin | `0` — and refused outright beside a stated absence, below |
781
+ | `width`, `height` | setup-pose bounding box size, **or both `null` for "this skeleton declares no stage"** | falls back to the manifest's crop; **with neither the number nor the `null`, the compile fails** |
678
782
  | `fps` | nonessential editor hint | `SkeletonData.fps` stays 30 |
679
783
  | `referenceScale` | 4.2+ physics/scale reference | parser default 100 |
680
784
  | `images` | where the editor's import looks for the part PNGs, as a path from the skeleton file | **written for you**: under `--copy-images` the `--out` directory itself, spelled `../<its basename>/` (a literal `./` is dropped by the editor on import; a named directory is kept and every part is found — measured on 4.3.23); otherwise the relative path from `--out` to the one directory the spec names every part PNG in (the rig's images directory, or the manifest's plates). A declared value is carried through verbatim — and overridden by `--copy-images`, which moved the parts. Parts spread over several directories have no single true path, so nothing is written (issue #370) |
@@ -690,6 +794,40 @@ links, not the editor that will open the file, and the warning is harmless
690
794
  `width`/`height` are what `A14` and `A19` measure against, so a guessed stage is a
691
795
  gate measuring against a number nobody wrote down.
692
796
 
797
+ ⭐ **A skeleton may declare no stage, and saying so is not the same as saying
798
+ nothing** (issue #578). Write the pair as `null`:
799
+
800
+ ```json
801
+ "skeleton": { "width": null, "height": null }
802
+ ```
803
+
804
+ and the emitted header carries **none** of `x`/`y`/`width`/`height` — which is
805
+ what an export of a skeleton whose stage was never set looks like, and the shape a
806
+ transcriber of one now has something to write. `null` is this spec's spelling for
807
+ a stated absence wherever it has one (`slots[].attachment` is `null` for "show
808
+ nothing"), so nothing new is introduced here but a third value of a field that
809
+ already existed.
810
+
811
+ Three readings stay apart, and the middle one is the point of the other two:
812
+
813
+ | What the spec says | What happens |
814
+ | --- | --- |
815
+ | a number for each | the stage, as before; a manifest `crop` is the fallback |
816
+ | `"width": null, "height": null` | builds, and emits a header with no stage at all — and this **beats** a manifest's `crop`, because a rig spec is where a claim about the skeleton is made |
817
+ | neither | **refused**, exactly as before: `no stage size: …` |
818
+ | one `null`, one number | refused — a stage has both extents or neither, and which half was meant is not derivable |
819
+ | the pair `null` **and** an `x` or `y` | refused — an origin for a box that is not there |
820
+
821
+ ⚠️ A stage-less skeleton is **unmeasured, not certified**: `A14_NO_FULL_FRAME_MESH`
822
+ reports **SKIP** on one, because there is no full frame for a mesh to span. And
823
+ `rigc diff` reports it — `skeleton.stage_present` and `skeleton.stage_box`, in the
824
+ 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
826
+ the reason every reported measure is: no reading of the rendered frames could have
827
+ decided a setup-pose bounding box. The measure inventory that says so lives in
828
+ [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md), which
829
+ is repository material and not in the published package.
830
+
693
831
  ### 3.2 `bones` — Spine's bone list
694
832
 
695
833
  `parent` is resolved **by name against bones already declared**, exactly as the
@@ -720,17 +858,38 @@ and the inheritance silently falls back to Normal — assertion `A02` refuses it
720
858
  | --- | --- | --- |
721
859
  | `name` | required, unique | — |
722
860
  | `bone` | required; must be a bone this rig declares | — |
723
- | `attachment` | the **setup pose** attachment name, or `null` for "show nothing" | must come from here or from `motion.setup` (R3) |
861
+ | `attachment` | the **setup pose** attachment name, or `null` for "show nothing" | must come from here or from `motion.setup` (R3) — **except** on a slot nothing fills, where it can only be `null` and may be left out |
724
862
  | `color` | `rrggbbaa` tint | opaque white |
725
863
  | `dark` | two-colour tint, `rrggbb` | — (🚫 `A12` under `spine-html`) |
726
864
  | `blend` | `normal` · `additive` · `multiply` · `screen` | `normal` |
727
865
 
728
- ⚠️ **A slot with no attachments is not emitted.** If nothing fills it — no skin
729
- entry, no manifest part — it is dropped from the skeleton without an error, and the
730
- emitted slots array is a *subsequence* of the rig's. That is deliberate: the rig's
731
- slot list is the canonical table and declaring a slot no cut fills is legitimate,
732
- because it fixes where that slot will sit when one does. It also means a typo in a
733
- skin's slot key can cost you a slot quietly, so check `explain`'s slot table.
866
+ ✅ **Every slot you declare is emitted, in this order.** A slot nothing fills — no
867
+ skin entry, no manifest part — is emitted **empty**: `name` and `bone`, and no
868
+ `attachment` key at all, which is how the format spells "shows nothing"
869
+ (`SkeletonJson`'s slot reader takes `attachment` with a `null` default; 34 of the 52 slots of the
870
+ official `spineboy-pro` export omit the key, though those are slots a skin fills
871
+ whose setup pose shows nothing). So the emitted slots array
872
+ **is** the rig's slot table, and `A26_SLOT_DRAW_ORDER` checks it in both directions:
873
+ nothing out of order, and nothing missing.
874
+
875
+ Declaring a slot no cut fills is therefore normal — it fixes where that slot sits
876
+ whether or not this cut has art for it — and transcribing a foreign skeleton that
877
+ carries an empty slot reproduces it exactly.
878
+
879
+ ⚠️ **This changed with issue #575.** Such a slot used to be *dropped*, with no message, and
880
+ the gate allowed the emitted array to be any subsequence of the rig's. What it cost
881
+ is the index: every slot below the dropped one moved up one place, which is what a
882
+ `drawOrder` key's offsets are counted against and what an index-keyed consumer
883
+ splits on. Two production exports declaring 53 and 61 slots built green at 51 and 57
884
+ and read 0.962 and 0.934 under `diff` against the file they were transcribed from.
885
+ If you have a rig that leaned on the drop, the emitted array simply grows; nothing
886
+ else about it moves.
887
+
888
+ 🚫 **Naming an attachment on a slot nothing fills is refused by name**: `the setup
889
+ pose shows attachment "x" on slot "y", which no skin and no manifest part fills`.
890
+ That is the half-finished wiring-up the old silence hid — the slot is emitted empty
891
+ and the name resolves to nothing, so either give the slot an attachment or state the
892
+ setup pose as `null`.
734
893
 
735
894
  ### 3.4 `skins` — placeholder → attachment maps
736
895
 
@@ -743,6 +902,33 @@ A skin can also say which bones and constraints it **switches on**, and that nee
743
902
  one more level, so a skin entry has a second spelling — see §3.4.1. The short one
744
903
  above is unchanged and is what almost every rig wants.
745
904
 
905
+ 🔸 **A skin may fill no slot with anything that needs art, and that build is
906
+ green.** The atlas is built out of what the skins reference, so a rig whose skins
907
+ name no `image` — an empty `default`, or one carrying only a `boundingbox`, a
908
+ `clipping` polygon or a `path`, none of which has a page — compiles to an atlas
909
+ with **no pages**, and rigc writes `skeleton.atlas` as an **empty file** (zero
910
+ bytes). Four rules then report SKIP by name rather than a pass, because a page is
911
+ their whole subject: `A07_ATLAS_TEXT_SHAPE`, `A06_ATLAS_PAGE_SIZE_MATCHES_PNG`,
912
+ `A17_ATLAS_PAGE_FILES_EXIST` and — under `spine-html` —
913
+ `A19_OVERLAY_PNGS_HAVE_ALPHA` and `A27_REGION_NAME_MATCHES_PAGE_FILENAME`. Until
914
+ [#608](https://github.com/firejune/rigc/issues/608) the same compile wrote one
915
+ newline instead and `A07` refused it with two findings, so a hit-box skeleton — a
916
+ correct rig, whose geometry `A33_VERTEX_ATTACHMENT_GEOMETRY` passes — could not be
917
+ built at all.
918
+
919
+ ⚠️ **This is not the case where an attachment WANTS a region.** A region or mesh
920
+ attachment that states `width`/`height` and names no `image` still resolves a
921
+ region by `path`, and with no atlas to supply it that is
922
+ `A08_REGION_NAMES_MATCH_ATTACHMENTS` naming the skin, the slot, the placeholder
923
+ and the path — which is what a spec written by `ingest --art none` does when it is
924
+ built without `--atlas-in` (§0.2, §0.3). The empty atlas is legal; an attachment
925
+ pointing into it is not.
926
+
927
+ ⚠️ **`rigc render` still refuses such a build**, by name and before it draws
928
+ anything: `… posed no drawable attachment in any animation or in its setup pose —
929
+ there is nothing to draw`. That is the honest division — the rig is valid Spine
930
+ data, and there is no picture of it.
931
+
746
932
  **Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
747
933
  the default `type`:
748
934
 
@@ -751,7 +937,7 @@ the default `type`:
751
937
  | `type` | `"region"`, or omit |
752
938
  | `image` | **rigc extension.** A PNG relative to the rig's `images` directory; rigc measures it (R5) |
753
939
  | `width`, `height` | required by the format — give them, or give an `image` |
754
- | `path` | the atlas region to resolve; defaults to the attachment's own name. rigc sets it for you when the PNG basename differs from the placeholder, and whenever it composes a `name` because more than one skin fills this placeholder (§3.4.2) |
940
+ | `path` | the atlas region to resolve; defaults to the attachment's own name. rigc sets it for you when the PNG basename differs from the placeholder, and whenever it composes a `name` because more than one skin fills this placeholder (§3.4.2). **One rule, both kinds** — see the note under *Mesh attachment* |
755
941
  | `x`, `y` | offset from the bone, in the bone's local space |
756
942
  | `rotation` | degrees; cancels a rotated bone for a plate authored screen-upright |
757
943
  | `scaleX`, `scaleY`, `color` | as Spine |
@@ -760,7 +946,23 @@ the default `type`:
760
946
  either authored geometry (`uvs` + `triangles` + geometry) **or** a `generator`,
761
947
  never both. `hull`, `edges`, `width` and `height` may be stated; whichever is
762
948
  omitted, rigc derives — `hull` and `edges` from the triangles, the size from the
763
- PNG — and the rules are a few paragraphs down.
949
+ PNG — and the rules are a few paragraphs down. `type`, `image`, `path` and `color`
950
+ mean exactly what they mean on a region.
951
+
952
+ 🔑 **`path` is one rule for both kinds.** A mesh derives it from `image` the way a
953
+ region does: stated wins, otherwise the PNG's basename when that differs from the
954
+ placeholder, otherwise nothing. The parser reads `path` off both with the same line
955
+ (`getValue(map, "path", name)`, `SkeletonJson.ts:541` and `:570`), and `path`
956
+ defaults to the attachment's **name** rather than to the placeholder — so a mesh
957
+ with `image: hair_short.png` under a placeholder called `hair` resolves the region
958
+ `hair`, which no atlas has. Until
959
+ [#577](https://github.com/firejune/rigc/issues/577) a region derived it and an
960
+ authored mesh did not, so that rig **built** and then failed
961
+ `A00_ROUNDTRIP_PARSE: threw: Region not found in atlas: hair` — which is the
962
+ loader's sentence and all the report had. Since
963
+ [#589](https://github.com/firejune/rigc/issues/589) the same miss is named by
964
+ `A08_REGION_NAMES_MATCH_ATTACHMENTS`, with the skin, the slot, the placeholder
965
+ and the attachment's own name beside the path.
764
966
 
765
967
  Geometry comes in one of two fields:
766
968
 
@@ -1492,12 +1694,31 @@ skin's entry as its placeholder and name the others. Trip 7 supported it and tri
1492
1694
  8 refuted it: that is the spelling the editor refuses at the door. There is no
1493
1695
  third spelling, which is why this is a refusal rather than a naming scheme.
1494
1696
 
1495
- Three things to know about it and nothing to author:
1496
-
1697
+ What to know about it, and nothing to author:
1698
+
1699
+ - **The renderer accepts this shape too, and that is measured rather than
1700
+ assumed.** `spine-html@0.4.1` resolves a part in two steps and neither one
1701
+ reads a placeholder or an attachment name: `DomTexture.js:78,102` builds its
1702
+ image map with `put(atlasRegion.name, …)` over every region of the atlas, and
1703
+ `SpineHtmlRenderer.js:172` reads it back as
1704
+ `const regionImage = region && this.regionImages.get(region.name)`, where
1705
+ `region` came off the attachment — which `AtlasAttachmentLoader` resolved
1706
+ through `path`. Every published version of that renderer keys the same way.
1707
+ ⚠️ `A08` used to carry a `--profile spine-html` clause requiring a
1708
+ placeholder to be spelled exactly like the region it resolves to, which made
1709
+ this shape and a green `spine-html` **mutually exclusive** from
1710
+ [#567](https://github.com/firejune/rigc/issues/567) onwards; the first
1711
+ production rig with named skins hit it three times. That clause is retired —
1712
+ restated as the join the renderer actually performs it was a tautology over
1713
+ the resolve check beside it
1714
+ ([#574](https://github.com/firejune/rigc/issues/574)).
1497
1715
  - **`path` is restated, and it has to be.** `path` defaults to the attachment's
1498
1716
  **name**, not to its placeholder, so an entry given a name and no path would
1499
- resolve its texture at `zulu/patch` and find no such region. `A00_ROUNDTRIP_PARSE`
1500
- says so in the parser's own words if it is ever dropped.
1717
+ resolve its texture at `zulu/patch` and find no such region.
1718
+ `A08_REGION_NAMES_MATCH_ATTACHMENTS` says so if it is ever dropped, naming the
1719
+ skin, the slot, the placeholder and the path
1720
+ ([#589](https://github.com/firejune/rigc/issues/589)); `A00_ROUNDTRIP_PARSE`
1721
+ reported it in the parser's own words until then, and now defers to A08.
1501
1722
  - **Only contested placeholders are touched.** One skin filling a placeholder, or
1502
1723
  two skins filling a slot under *different* placeholders, emit exactly what they
1503
1724
  always did — every rig in this repository is byte-identical across the change.
@@ -1525,6 +1746,15 @@ bounding box in slot `head-bb`, and reuses `hoverglow-small` across eight slots.
1525
1746
  a name shared between slots is normal and rigc leaves it alone; what #541 refused
1526
1747
  was one slot holding two.
1527
1748
 
1749
+ 🚨 **Once a rig has named skins, no instrument here can see them until you say
1750
+ which one** ([#571](https://github.com/firejune/rigc/issues/571)). `render` and
1751
+ `check` set no skin unless told to, so every slot resolves through the *default*
1752
+ skin alone and the art you just moved into `base`, `zulu` and `mike` draws
1753
+ nothing at all. `check` then compares blank against blank and reports a perfect
1754
+ `0.0000` — about the very placeholder this subsection is about. Pass
1755
+ `--skin <name>` to both, once per skin (**§9**); `tools/editor_roundtrip.ts`
1756
+ loops over every skin the build declares for the same reason.
1757
+
1528
1758
  ### 3.5 `constraints` — 4.3's single typed array
1529
1759
 
1530
1760
  Spine 4.3 folds every constraint into one `constraints` array with a `type`
@@ -2131,7 +2361,8 @@ a deform). Folding them in would make `v` mean four different things depending o
2131
2361
  | `bone` | `translatex`, `translatey`, `scalex`, `scaley`, `shearx`, `sheary`, `rotate` | `[value]` |
2132
2362
  | `slot` | `rgba` | `[r, g, b, a]` in 0..1 |
2133
2363
  | `slot` | `attachment` | the attachment name, or `null` for "show nothing" |
2134
- | `physics` | `mix` | `[mix]`, 0..1 — the constraint's authority |
2364
+ | `physics` | `inertia`, `strength`, `damping`, `mass`, `wind`, `gravity` | `[value]` — the constraint's own tuning, keyed over time |
2365
+ | `physics` | `mix` | `[mix]`, **0 or more** — the constraint's authority |
2135
2366
  | `physics` | `reset` | `null` — the key *is* the event |
2136
2367
  | `path` | `position`, `spacing` | `[value]` — see §4.12 |
2137
2368
  | `path` | `mix` | `[mixRotate, mixX, mixY]` — one timeline, three channels |
@@ -2141,6 +2372,42 @@ a deform). Folding them in would make `v` mean four different things depending o
2141
2372
  Translate values are **relative to the bone's setup position**; scale values are
2142
2373
  multipliers where `1` is setup; rotation is in degrees.
2143
2374
 
2375
+ **A physics constraint's six tuning timelines override §4.6's table for the
2376
+ length of an animation.** `{ "physics": "hair", "property": "wind", "keys": […] }`
2377
+ is a wind that rises and falls; `damping` is how fast the jiggle settles,
2378
+ `strength` how hard it is pulled back, `inertia` how much of the bone's motion it
2379
+ carries, `mass` the weight it swings with, `gravity` the constant pull. They are
2380
+ the same field names §4.6 uses at rest, and one key states the whole value — not
2381
+ a delta from the constraint's own setting.
2382
+
2383
+ - ⚠️ **The per-key default is 0 on all six, and 1 on `mix` — not the
2384
+ constraint's default.** The parser opens every physics key at 0 and only
2385
+ `mix` reassigns it, so a key that omits its number reads 0 for `damping`, not
2386
+ the 0.85 §4.6 gives a constraint that states none. rigc never omits a channel,
2387
+ so this bites only when you compare an emitted file against an editor export,
2388
+ or when you read an export by hand: a `damping` key with no `value` in
2389
+ somebody else's file means **0**.
2390
+ - ⚠️ `mass` is the one whose keyed number is not what the runtime stores. The
2391
+ key states a mass and the pose holds `1 / mass`, so a `mass` key of `0` is an
2392
+ infinite inverse mass — the constraint stops moving.
2393
+ - **Four of the seven are bounded, and a key outside its bound is a compile
2394
+ error** ([#610](https://github.com/firejune/rigc/issues/610)). `mass` and
2395
+ `strength` must be `> 0`, `damping` must be strictly inside `(0, 1)`, and `mix`
2396
+ must be `0` or more. `A23_PHYSICS_CONSTRAINT_EFFECTIVE` applies the same four to
2397
+ a file rigc did not write, naming the animation, the constraint, the key time
2398
+ and the value — so the compiler is where a spec you wrote is refused, and the
2399
+ assertion is where an import is.
2400
+ - 🚫 **`inertia`, `wind` and `gravity` are bounded nowhere, and neither is the top
2401
+ of `mix`.** The runtime documents no range for the first three, and
2402
+ `PhysicsConstraintPose` documents `mix` as "a percentage (0+)" — so a negative
2403
+ wind is the other direction and a `mix` of `1.5` is an over-mix. Both are real
2404
+ and both compile.
2405
+ - ⚠️ **A `mix` key of exactly `0` is legal where a setup `mix` of `0` is not**, and
2406
+ the difference is not an inconsistency. `PhysicsConstraint.update` opens with
2407
+ `if (mix === 0) return;`, so muting a constraint for a stretch of an animation is
2408
+ what a mix timeline is for; a constraint muted *at rest* does nothing at all
2409
+ unless some animation rescues it, which is the silence `A23` was built for.
2410
+
2144
2411
  On a track that names a `group`, one key's `v` may instead be a **map keyed by
2145
2412
  member name**, whose entries are each exactly the `v` above — or a `derive`
2146
2413
  model the compiler evaluates per member. §4.5.1.
@@ -2426,8 +2693,11 @@ key times. One lag, one place.
2426
2693
 
2427
2694
  `name → { bone, x?, y?, rotate?, scaleX?, shearX?, inertia?, strength?, damping?,
2428
2695
  mass?, wind?, gravity?, mix?, fps?, limit? }`. These are emitted into the 4.3
2429
- `constraints` array. `mass: 0` becomes an infinite inverse mass and `damping ≥ 1`
2430
- never settles — both are `A23`. Every field but `bone` and `note` must be a finite
2696
+ `constraints` array. Seven of them — the six tuning numbers and `mix` — can also
2697
+ be **keyed over time** as `tracks` entries naming this constraint (§4.4); this
2698
+ table is the value at rest, and a timeline overrides it while it plays. `mass: 0` becomes an infinite inverse mass and `damping ≥ 1`
2699
+ never settles — both are `A23`, here and on every timeline key that states them
2700
+ ([#610](https://github.com/firejune/rigc/issues/610)). Every field but `bone` and `note` must be a finite
2431
2701
  number: a non-number is rounded to `NaN` and emitted as `null`, which the runtime
2432
2702
  reads as **zero**, so `"mass": "heavy"` used to ship a constraint that never
2433
2703
  settles with no word from anybody (#307).
@@ -2586,6 +2856,18 @@ here was measured off a real rig. Copy the shape, not the values.
2586
2856
  runtime. **Stating a flag on every key still overrides the rig** — the format
2587
2857
  keys them per key on purpose, a bend that flips partway through is a real thing
2588
2858
  to write, and it is what the editor's own export does.
2859
+ - 🔁 **A spec that came out of `ingest` (§0.3) states all three on every key, and
2860
+ that is not noise.** The stamping above reads a silent key as *"the rig's
2861
+ value"*, which is right for a spec somebody wrote; in a **decompiled** spec a
2862
+ silent key means *"the parser's default"*, because that is what the export the
2863
+ spec came from actually plays. The two readings differ exactly when the rig
2864
+ declares a non-default flag and the export's keys omit it — measured: an ik
2865
+ timeline keying only `mix` under a constraint declaring `bendPositive: false`
2866
+ rebuilds with `bendPositive: false` on every key if the flag is left silent,
2867
+ and with `true` — what the source plays — when `ingest` writes it out. So
2868
+ `ingest` restates every field any key of a track names, at the parser's default
2869
+ where the source omits one, and records a `CONSTRAINT_KEY_RESTATED` finding. On
2870
+ rigc's own output the two agree and the round trip is byte-identical.
2589
2871
  - `mix` outside `0..1` is a compile error: `IkConstraintPose.mix` is documented as
2590
2872
  a percentage. A **transform** mix is documented *unbounded*, which is why §4.10
2591
2873
  has no such rule — the asymmetry is the runtime's, not ours.
@@ -2706,7 +2988,7 @@ Per key:
2706
2988
 
2707
2989
  | Field | Meaning |
2708
2990
  | --- | --- |
2709
- | `vertices` | the run: `x, y` offset pairs. **Absent** = back to the setup pose |
2991
+ | `vertices` | the run: consecutive numbers copied into the deform array from the start index on, `x, y` per vertex or influence — an **odd** count is legal and ends on a lone x (§ the 📌 below). **Absent** = back to the setup pose |
2710
2992
  | `transform` | the run stated as a **model** instead, evaluated over the attachment's own geometry — §4.11.1. Never with `vertices`, `fromVertex` or `offset` |
2711
2993
  | `fromVertex` | which VERTEX the run starts at — rigc translates it |
2712
2994
  | `offset` | the same start as a raw index into the deform array. Never with `fromVertex` |
@@ -2756,14 +3038,12 @@ geometry to the next one's. So a named `ease` behaves as it does anywhere, and a
2756
3038
  raw `curve` is 4 numbers whose value axis runs 0..1, not the range of your vertex
2757
3039
  offsets.
2758
3040
 
2759
- rigc refuses six things here, and the first is the quietest defect in the whole
2760
- animation half of the format:
3041
+ rigc refuses these, and the first is the quietest defect in the whole animation
3042
+ half of the format:
2761
3043
 
2762
3044
  | You wrote | You get |
2763
3045
  | --- | --- |
2764
3046
  | a run that ends past the array | `the run starts at deform index 4 and is 6 long, which ends at 10; this attachment's deform array is 8 long (4 vertices)` |
2765
- | an odd `offset` | `offset 3 is odd. The deform array is x, y pairs, so an odd start puts every x of this run on a y` |
2766
- | an odd number of `vertices` | `"vertices" holds 3 numbers; the deform array is x, y PAIRS` |
2767
3047
  | `fromVertex` on a multi-bone vertex | `"fromVertex" counts VERTICES, and this attachment is weighted … vertex 2 has 2 of them` |
2768
3048
  | an attachment that is not there | `slot "flat" in skin "default" has no attachment "flatt" (it has: flat)` |
2769
3049
  | a deform on a region attachment | `a deform timeline keys the vertices of an attachment, and this one is a "region"` |
@@ -2781,15 +3061,30 @@ tail and deforms the rest of the mesh correctly, which looks nearly right, and
2781
3061
  emitted file, measuring the array's length from the attachment rather than assuming
2782
3062
  an encoding.
2783
3063
 
2784
- 📌 **The two parity rows above are an AUTHORING rule and not a validity one, and
2785
- the distinction is deliberate** (issue #262). Nothing in the runtime aligns a run
2786
- to a pair — `arrayCopy` copies at the raw index and the `deform[i] += vertices[i]`
2787
- after it walks the whole array — so a run may legitimately begin and end mid-pair,
2788
- which is what an editor's trimmed delta run looks like. In *this* spec an odd
2789
- `offset` is a typo with a better spelling (`fromVertex`), so it is refused here,
2790
- where the remedy is a line you own. `A35` does **not** refuse it: it is pointed at
2791
- other people's files, and a rule stricter than the runtime tells its reader to go
2792
- and break correct data.
3064
+ 📌 **A run is not required to be an even number of numbers, and it is not required
3065
+ to start on an even index.** Two rows stood here saying otherwise until issue
3066
+ #576. Nothing in either reader aligns a run to a pair: `SkeletonJson` does
3067
+ `Utils.arrayCopy(vertices, 0, deform, offset, vertices.length)` — a raw copy, at
3068
+ the raw index the key gives — and `SkeletonBinary` reads a count and a start and
3069
+ fills `for (let v = start; v < end; v++)`. So a run may begin and end mid-pair,
3070
+ which is exactly what an editor's trimmed delta run looks like: `spineboy-pro`'s
3071
+ `hoverboard-board` key starts at 1 and carries 147 numbers of a 148-long array,
3072
+ the whole delta minus one leading zero.
3073
+
3074
+ ⭐ **An odd run's last number is an x with no y beside it, and that is a
3075
+ statement, not an accident.** It moves that vertex in x and leaves its y at the
3076
+ setup value, because the parser copies your numbers and touches nothing else. It
3077
+ is also the reason the rule could not stay as an authoring convenience: padding a
3078
+ `0` to make the run even *changes what plays* wherever that setup y is non-zero,
3079
+ so there is no second spelling of such a key — refusing it would mean no
3080
+ transcription of that file can be written in this spec at all. `A35` reached the
3081
+ same conclusion from the other side in issue #262, and between that fix and this
3082
+ one the two halves of rigc disagreed about what the format holds.
3083
+
3084
+ ⚠️ What you lose with those rows is a typo filter: `offset: 3` written where
3085
+ `fromVertex: 3` was meant now compiles. It always half-did — `offset: 4` meant as
3086
+ vertex 4 lands on vertex 2 and was never refused — so read the field name twice.
3087
+ `offset` is an index into the deform array; `fromVertex` is a vertex.
2793
3088
 
2794
3089
  🖼️ **Worked examples, and they use a deform for four different things** — all
2795
3090
  four are repository material rather than part of the published package, so the
@@ -3096,6 +3391,13 @@ keys were passed over and how many reversed triangles nothing gated:
3096
3391
  8 reversed triangle(s) nothing gates <- A39 counts them as deformKeysNotDrawn
3097
3392
  ```
3098
3393
 
3394
+ ⭐ **On the "shows another attachment" half, that sentence also names the skin the
3395
+ pose was taken in** — `the slot shows attachment "away" at this time, with skin
3396
+ "suit" worn, not this mesh …` — because what a slot shows is resolved through the
3397
+ worn skin first and `defaultSkin` second, so the reading is only meaningful beside
3398
+ the dress it was taken in (§4.11.5). The alpha half carries no such clause: an
3399
+ alpha is read off the pose and no skin is in it.
3400
+
3099
3401
  🚫 **What it does not print, and why — `coverage`.** A deform **cannot move
3100
3402
  coverage.** That figure is rasterised from the attachment's **uvs** against the
3101
3403
  part's alpha, and a deform moves positions and never uvs, so it is identical at
@@ -3371,6 +3673,70 @@ records that, so a slider-applied animation is measured in its slider frames onl
3371
3673
 
3372
3674
  ---
3373
3675
 
3676
+ ### 4.11.5 Which skin a deform key is measured in — the one it is keyed on
3677
+
3678
+ §4.11 opens on the triple: a deform timeline is the only one keyed on
3679
+ **skin / slot / attachment**. So the skin is not context around the key, it is a
3680
+ third of the key's own address — and every pose `A39` and the `DEFORM` block take
3681
+ is now taken with **that skin worn**
3682
+ ([#583](https://github.com/firejune/rigc/issues/583)). You do not ask for it and
3683
+ there is no flag: the skin comes out of the timeline.
3684
+
3685
+ 🚨 **It used to wear nothing at all**, which is `spine-core`'s own initial state
3686
+ and the same one `check` reports as `no skin set (the default skin alone)`
3687
+ (§9) — every slot resolved through `SkeletonData.defaultSkin` and nothing else.
3688
+ Move a deformed mesh into a named skin, which the format not only allows but keys
3689
+ the timeline on, and the slot showed **no attachment**. This is what
3690
+ `gallery/squash`'s ball printed with its one mesh moved into a skin `suit`, on
3691
+ every one of its five keys, before #583:
3692
+
3693
+ ```
3694
+ DEFORM bounce suit/ball/ball key 1 t=0.340000 transform affine scale=[0.88, 1.16]
3695
+ skipped A39 reads no winding off this key: the slot shows no attachment at all at this
3696
+ time, not this mesh, so the runtime applies no deform to it here and draws
3697
+ none of it — a triangle that draws no pixels cannot draw them backwards
3698
+ ```
3699
+
3700
+ — `A39` went **PASS → SKIP** on a rig whose only edit was which skin one mesh sat
3701
+ in, in a sentence that reads as a verdict on that rig. Worn, the same build
3702
+ reports every figure the default-skin one does, to the last digit, and `A39` gates
3703
+ it: the two `DEFORM` blocks differ in the skin of the triple and in nothing else.
3704
+
3705
+ **Two things follow, and one of them is not about art:**
3706
+
3707
+ - ⚠️ **A "nothing is drawn" sentence now names the skin it was measured in** —
3708
+ `the slot shows attachment "away" at this time, with skin "suit" worn, not this
3709
+ mesh …`. It is on the *shows-something-else* branch only, because that is the
3710
+ branch a skin decides; an alpha is read off the pose and has no skin in it. The
3711
+ clause is what separates "this slot is empty" from "this slot is empty in the
3712
+ dress this mesh lives in", and only the second is a measurement.
3713
+ - ⭐ **A `skin: true` bone or constraint (§3.4.1) is switched on too.**
3714
+ `Skeleton.setSkin` calls `updateCache`, which leaves a `skinRequired` bone
3715
+ inactive and a `skinRequired` constraint out of the update cache under any skin
3716
+ that does not list it. So a **slider** dressed into the same skin as the mesh
3717
+ it drives used to be measured with itself switched off, and failed in two ways
3718
+ depending on its `local` flag: a world-read property never moved, so the frame
3719
+ was reported as *"played on a track"* — an animation a slider is the only way
3720
+ into (§4.11.4) — while a local-read one kept its mapping but never left
3721
+ `SliderPose.time` 0, so every key came back as *"at a time no dial selects"*.
3722
+ Both now reach their own key times.
3723
+
3724
+ ⛔ **What it does not do is try every skin.** `Attachment.timelineSlots` lets one
3725
+ deform reach a second slot — a linked mesh with `inheritTimelines` — and that copy
3726
+ may live in a skin of its own; `setSkin` dresses the whole skeleton at once, so
3727
+ under the timeline's own skin that copy resolves to nothing and is reported as
3728
+ drawing nothing, which is exactly what the runtime does with that same skin on.
3729
+ Posing the key again under some other skin would be rigc choosing which dress the
3730
+ character is wearing, and which skin is worn is yours. What it owes you instead is
3731
+ the skin in the sentence, so the reading is never mistaken for a verdict.
3732
+
3733
+ 📌 **`check` is the other half of the same question and it is not automatic**:
3734
+ a rig whose art lives in named skins is rendered and checked **once per skin**,
3735
+ with `--skin` on both sides (§9). The difference is where the name comes from —
3736
+ a deform timeline carries its own skin and a frame set does not.
3737
+
3738
+ ---
3739
+
3374
3740
  ### 4.12 `path` and `slider` timelines — tracks, not their own groups
3375
3741
 
3376
3742
  Unlike `ik` and `transform`, these two are ordinary `tracks` entries: the format
@@ -3509,9 +3875,13 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3509
3875
  | `bone "X" names parent "Y", which is not declared before it` | move `Y` earlier in `bones` |
3510
3876
  | `two bones are called "X"` | bone names are the join key; rename one |
3511
3877
  | `slot "X" names bone "Y", which this rig does not declare` | add the bone, or fix the slot's `bone` |
3512
- | `no setup pose for slot "X": give the motion spec a \`setup\` entry or the rig slot an \`attachment\`` | R3 — pick one file and declare it there |
3878
+ | `no setup pose for slot "X": give the motion spec a \`setup\` entry or the rig slot an \`attachment\`` | R3 — pick one file and declare it there. A slot **nothing** fills is exempt: its setup pose can only be "show nothing" and is not asked for |
3879
+ | `the setup pose shows attachment "A" on slot "X", which no skin and no manifest part fills` | §3.3 — the slot is emitted empty, so `A` resolves to nothing. Give the slot an attachment (a skin entry or a manifest part), or state the setup pose as `null` |
3513
3880
  | `a region needs width and height — give them, or give an "image" and rigc will measure the PNG` | add `image`, or both sizes |
3514
3881
  | `a mesh needs width and height — give them, or give an "image" and rigc will measure the PNG` | §3.4 — the same rule for a mesh |
3882
+ | `"type" is null, which is not a name. An attachment's type is one of region, mesh, linkedmesh, … or the key is absent and reads as "region"` | §6 — **remove the key**. Absent is the format's own default; present-and-null matches no parser case and the attachment is dropped in silence |
3883
+ | `attachment type "X" is not one of the 7 the Spine 4.3 format defines (…)` | §6 — a name the format does not have. Not a deferral, and not something rigc will grow: fix the spelling (`sequence` is a key on a region or a mesh, not a type) |
3884
+ | `this attachment is a "linkedmesh"` / `"point"` … `rigc does not emit it yet` | §6 — a construct the format has and rigc does not write. The message says what it would carry; SPEC_COVERAGE part 1-6 is the row it reads from |
3515
3885
  | `hull N disagrees with the triangles, whose outline has K vertices (0 → …)` | §3.4 — delete `hull`, or state K |
3516
3886
  | `hull vertices must come first; vertex i is on the boundary and vertex j is not. The triangles' outline runs …: list those K vertices first, in that order, then the M interior vertices` | §3.4 — renumber the vertices: the printed walk first, then the interior |
3517
3887
  | `hull vertices must trace the outline in order; the triangles' outline runs …, so vertex a has to follow vertex b in the list, and vertex c does` | §3.4 — renumber along the printed walk |
@@ -3525,6 +3895,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3525
3895
  | `animation "A" slot "X" attachment: key at Ns is Ms past the declared duration Ds` | §4.5 — the key is past the end of the animation and nothing will sample it. Move the key onto `duration`, or raise `duration` |
3526
3896
  | `animation "A" keys unknown bone "X"` | the track's `bone` is not in the rig |
3527
3897
  | `animation "A" bone "X" translatex: key value must be an array of 1 number(s)` | the value shape must match the property (§4.4) |
3898
+ | `animation "A" physics constraint "C" mass key at t=… is 0 (massInverse Infinity); must be > 0 — …` | §4.4 — a keyed physics value the runtime cannot use. The message names the bound and the `PhysicsConstraint.js` lines that make it one: `mass` and `strength` are `> 0`, `damping` is inside `(0, 1)`, `mix` is `0` or more, and `inertia`/`wind`/`gravity` are bounded nowhere ([#610](https://github.com/firejune/rigc/issues/610)) |
3528
3899
  | `a key carries both a named easing and a raw curve; pick one` | R6 |
3529
3900
  | `last key carries an easing but has nothing to ease to` | drop `ease`/`curve` from the final key |
3530
3901
  | `key times must strictly increase (at t=…)` | including after `lag` and `stagger` |
@@ -3541,7 +3912,9 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3541
3912
  | `animation "A" group "G" P (t=…): derive <kind> projects onto "scaleX" and member "M" states depth −z` | §4.5.1 — a foreshortening needs the part in front of the axis; a part behind it takes the displacement projection |
3542
3913
  | `animation "A" group "G" P (t=…): derive <kind> states carried=… on a "scalex" track` | §4.5.1 — the foreshortening reads no depth difference, so `carried` belongs on the displacement track |
3543
3914
  | `animation "A" group "G" P (t=…): derive <kind> turns member "M" … past its own edge` | §4.5.1 — `cos(α − t) ≤ 0` would mirror the drawing; the turn is past what this construction carries (FACE §8) |
3544
- | `no stage size: give the rig spec a \`skeleton.width\`/\`skeleton.height\`` | §3.1 |
3915
+ | `no stage size: give the rig spec a \`skeleton.width\`/\`skeleton.height\`` | §3.1 — or state `"width": null, "height": null` if the skeleton you are transcribing declares no stage |
3916
+ | `"skeleton" states width: null and a height of N` / `states height: null and a width of N` | §3.1 — a stage has both extents or neither |
3917
+ | `"skeleton" declares no stage (width: null, height: null) and still states x` | §3.1 — an origin for a box that is not there |
3545
3918
  | `N mesh slot(s) emitted but the rig "X" allows 0 — a mesh rigc GENERATED counts against \`invariants.meshSlots\`…` | §3.4 / §3.7 — a rig that invokes a mesh generator declares the budget; undeclared is zero. Add `"invariants": { "meshSlots": N, "meshTriangles": M }` |
3546
3919
  | `drawOrder at t=…: slot "X" is not one this rig emits` / `is offset twice in one key` / `puts it at N, outside the … emitted slots` | §4.7 |
3547
3920
  | `events at t=…: event "X" is not declared in the rig spec's "events" block` | declare it in the rig spec (§3.6), or fix the name |
@@ -3556,7 +3929,6 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3556
3929
  | `ik constraint "X": key 0 names "softness" and key 1 (t=…) does not` | §4.9 — every key is read with its own default, so state the field on every key or on none |
3557
3930
  | `ik constraint "X" (t=…): mix is 1.5, outside 0..1` | §4.9 — an IK mix is a percentage; a transform mix is unbounded |
3558
3931
  | `deform …: the run starts at deform index 4 and is 6 long, which ends at 10; this attachment's deform array is 8 long` | §4.11 — shorten the run or move its start; the parser would drop the tail in silence |
3559
- | `deform … (t=…): offset 3 is odd` | §4.11 — in **this spec** a run starts on an even index, or names its vertex with `fromVertex`. Not a validity rule; the runtime allows either (issue #262) |
3560
3932
  | `deform …: "fromVertex" counts VERTICES, and this attachment is weighted … vertex 2 has 2 of them` | §4.11 — key the control bone, or write bind-space pairs and start with `offset` |
3561
3933
  | `deform …: slot "X" in skin "default" has no attachment "Y" (it has: …)` | §4.11 — fix the placeholder name |
3562
3934
  | `deform … (t=…): the key carries both a "transform" and a "vertices" run` | §4.11.1 — a model and a table are two answers to one question; drop one |
@@ -3600,6 +3972,18 @@ The report prints one line per assertion:
3600
3972
  - **PASS** — it ran and held.
3601
3973
  - **SKIP** — it had *nothing to look at*, and the reason says what was missing. A
3602
3974
  skip is never folded into the pass count.
3975
+ 🚨 **"Nothing to look at" includes a subject the skeleton does not carry**
3976
+ ([#580](https://github.com/firejune/rigc/issues/580)). A rule named
3977
+ ⟨subject⟩_⟨property⟩ — `A03_REGION_WIDTH_HEIGHT_FINITE`,
3978
+ `A04_MESH_TRIANGLES_AND_ENCODING`, `A23_PHYSICS_CONSTRAINT_EFFECTIVE` — walks
3979
+ that subject and measures each member, so a skeleton with no region, no mesh or
3980
+ no physics constraint gives it nothing and it **SKIPs**, naming the subject. A
3981
+ rule named NO_⟨construct⟩ — `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS`,
3982
+ `A02_NO_BONE_TRANSFORM_KEY`, `A11_NO_CLIPPING_ATTACHMENTS`,
3983
+ `A12_NO_DARK_COLOR`, `A14_NO_FULL_FRAME_MESH` — asks how many of a thing the
3984
+ artifact carries, and **zero is the answer**, so it **PASSes**. ⇒ On a foreign
3985
+ export the SKIP list is a free inventory of what the skeleton does not contain,
3986
+ and the PASS list never contains a rule that looked at nothing.
3603
3987
  - **PROF** — the profile you chose does not carry that kind of rule. Two kinds sit
3604
3988
  outside `spine`, not one: the renderer rules **and** the archetype rules. The
3605
3989
  exclusion is checked before the assertion's body runs, so an archetype rule with
@@ -3609,48 +3993,70 @@ The report prints one line per assertion:
3609
3993
  - **FAIL** — the detail names the object, the value found and the value required.
3610
3994
  That detail is the instruction; the table below says which file to change.
3611
3995
 
3996
+ **Every assertion leaves exactly one row, on every run.** The four kinds partition
3997
+ the registry, so the rows you can see are the whole of what was asked — there is
3998
+ no fifth state in which a rule quietly did not come up. The last line of the
3999
+ report states that partition, and every figure in it is a count of *assertions*
4000
+ you can reproduce by counting rows:
4001
+
4002
+ ```
4003
+ .. <N> assertions: <M> measured (<P> passed, <F> failed), <S> skipped, <X> not in profile "<profile>"
4004
+ ```
4005
+
4006
+ `<M>` is `<P> + <F>`, and `<N>` is all four added together. ⚠️ `<F>` counts
4007
+ assertions and not `FAIL` lines: one assertion that finds six wrong vertices
4008
+ prints six rows and is one failure here.
4009
+
4010
+ 🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
4011
+ nothing else.** Most of the rules read the skeleton or the atlas that A00 loads,
4012
+ and with no parse there is nothing for them to look at — so they report `SKIP`
4013
+ naming that, *the round trip did not produce a skeleton to measure* or *…an atlas
4014
+ to measure*, and the summary's `<S>` goes up while `<M>` collapses. A run in that
4015
+ state is not a rig that nearly passed; it is a rig that was measured on one rule.
4016
+ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4017
+
3612
4018
  | Assertion | Profile | What tripped it, and where to fix it |
3613
4019
  | --- | --- | --- |
3614
- | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Everything else in the report is downstream of this one — fix it first |
4020
+ | `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 ([#568](https://github.com/firejune/rigc/issues/568)). ⚠️ **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 ([#589](https://github.com/firejune/rigc/issues/589)) |
3615
4021
  | `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 |
3616
4022
  | `A02_NO_BONE_TRANSFORM_KEY` | both | a bone uses 4.2's `transform`; rename it `inherit` in the rig spec |
3617
- | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height` |
3618
- | `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) |
3619
- | `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** |
3620
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk. Under `spine-html` also: `pma`, rotation, and a page that is neither **one part covering it exactly** (the unpacked convention) nor a **tiling** — a page whose regions all sit inside it and none of which overlap ([#266](https://github.com/firejune/rigc/issues/266)). A packed atlas therefore gates under this profile; what the message names is the region that runs off its page, or the pair that shares texels |
3621
- | `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 |
3622
- | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both ◑ | an attachment resolves to a region the atlas does not have — usually a `path`/`image` basename mismatch. Under `spine-html` the placeholder and the region name must also be *identical* |
4023
+ | `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 ([#580](https://github.com/firejune/rigc/issues/580)) |
4024
+ | `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 ([#580](https://github.com/firejune/rigc/issues/580)) |
4025
+ | `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 ([#580](https://github.com/firejune/rigc/issues/580)). 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 |
4026
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk. Under `spine-html` also: `pma`, rotation, and a page that is neither **one part covering it exactly** (the unpacked convention) nor a **tiling** — a page whose regions all sit inside it and none of which overlap ([#266](https://github.com/firejune/rigc/issues/266)). A packed atlas therefore gates under this profile; what the message names is the region that runs off its page, or the pair that shares texels. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4027
+ | `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. ⚠️ 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` |
4028
+ | `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. Until [#589](https://github.com/firejune/rigc/issues/589) they were unreachable: the loader threw first and the miss arrived as `A00_ROUNDTRIP_PARSE`. There is no `spine-html` clause here any more — a placeholder is free to differ from the region its `path` names ([#574](https://github.com/firejune/rigc/issues/574)) **SKIP** when no attachment names a region *and* the atlas declares none — both of its subjects at once ([#580](https://github.com/firejune/rigc/issues/580)) |
3623
4029
  | `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 |
3624
- | `A10_NO_NAN_AFTER_STEPPING` | both | stepping the animation produced a `NaN` pose. Look for a degenerate curve or a zero scale |
4030
+ | `A10_NO_NAN_AFTER_STEPPING` | both | stepping the animation produced a `NaN` pose. Look for a degenerate curve or a zero scale. **SKIP** when the skeleton carries no animation ([#580](https://github.com/firejune/rigc/issues/580)): the NaN is produced by stepping, and a static rig is never stepped — the same subject `A09` skips on |
3625
4031
  | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | a clipping attachment; the target renderer skips them silently |
3626
4032
  | `A12_NO_DARK_COLOR` | renderer | a slot `dark` colour or an `rgba2`/`rgb2` timeline; parsed, then ignored |
3627
- | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither — which means *unmeasured*, not that the budget is inert: the same `meshSlots` is a **compile-time** refusal for rigc's own generators, before the gate (§3.7, issue #274) |
3628
- | `A14_NO_FULL_FRAME_MESH` | renderer | a mesh spans the whole stage — a full-frame canvas that can never dirty-skip |
3629
- | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone |
4033
+ | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither — which means *unmeasured*, not that the budget is inert: the same `meshSlots` is a **compile-time** refusal for rigc's own generators, before the gate (§3.7, issue #274) **SKIP** also when the rig budgets **only** triangles and the skeleton carries no mesh ([#580](https://github.com/firejune/rigc/issues/580)). A declared slot budget still PASSes there, because zero mesh slots is a count measured against a ceiling |
4034
+ | `A14_NO_FULL_FRAME_MESH` | renderer | a mesh spans the whole stage — a full-frame canvas that can never dirty-skip. **SKIP** when the skeleton declares no stage (§3.1): there is no full frame to span, and *unmeasured* must not print the same green as *measured and clear* |
4035
+ | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone. **SKIP** when there is no `idle` animation, or when the one there is carries no bone timeline — a rule whose subject does not exist is unmeasured and not satisfied ([#568](https://github.com/firejune/rigc/issues/568)) |
3630
4036
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
3631
- | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out` |
4037
+ | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) — as it is for `A06`, `A19` and `A27`; see `A07` ([#608](https://github.com/firejune/rigc/issues/608)) |
3632
4038
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
3633
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) |
3634
- | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, or a binding at weight 0 |
4039
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4040
+ | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, or a binding at weight 0. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
3635
4041
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1 |
3636
- | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count |
3637
- | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, is muted by `mix: 0`, has `mass: 0`, has `strength: 0`, or has `damping` outside `(0, 1)` so it never settles |
3638
- | `A24_AXIS_SPACE_STROKE` | archetype | a bone under the rig's `axisBone` was keyed with a screen-space Y component, or the axis bone itself was keyed |
4042
+ | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4043
+ | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, is muted by `mix: 0`, has `mass: 0`, has `strength: 0`, or has `damping` outside `(0, 1)` so it never settles — **at rest, and on every physics timeline key** ([#610](https://github.com/firejune/rigc/issues/610)). The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. One difference between the two arms, and the runtime is the reason for it: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. **SKIP** when the skeleton declares no physics constraint ([#580](https://github.com/firejune/rigc/issues/580)) — the same sentence `A36` and `A37` have always printed for their own constraint types |
4044
+ | `A24_AXIS_SPACE_STROKE` | archetype | a bone under the rig's `axisBone` was keyed with a screen-space Y component, or the axis bone itself was keyed. **SKIP** when the rig declares no axis bone, and also when no animation keys that bone or anything under it ([#580](https://github.com/firejune/rigc/issues/580)) |
3639
4045
  | `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares `detached` is a descendant of the bone it must never hang under |
3640
- | `A26_SLOT_DRAW_ORDER` | archetype | the emitted slots are not a subsequence of the rig's slot table — a slot is out of order, or is not in the table at all |
3641
- | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | renderer | a single-region page whose region name is not the PNG's basename |
4046
+ | `A26_SLOT_DRAW_ORDER` | archetype | the emitted slots are not the rig's slot table — a slot is out of order, is not in the table at all, or is in the table and missing from the skeleton (§3.3). **SKIP** when the rig declares no canonical slot order. ⚠️ A skeleton with **no** slot beside a rig that declares some is **not** a skip, and it is the one rule in this family where an empty loop is not a vacuous pass ([#580](https://github.com/firejune/rigc/issues/580)): the completeness clause reads it as every declared slot lost and names them, which is the maximal case of what [#575](https://github.com/firejune/rigc/issues/575) filed |
4047
+ | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | renderer | a single-region page whose region name is not the PNG's basename. **SKIP** when the atlas declares no region ([#580](https://github.com/firejune/rigc/issues/580)) |
3642
4048
  | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | the two vertices of a ribbon row carry different weights, so the strip would change width. **SKIPs** on authored geometry and on a contour mesh — neither has rows rigc paired |
3643
4049
  | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | the animation drives deeper than the manifest's measured contact depth |
3644
4050
  | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | the animation drives past the measured containment ceiling, or scales a bone in the axis subtree |
3645
4051
  | `A31_DRAW_ORDER_OFFSETS_RESOLVE` | both | a draw-order key names a slot the skeleton does not have, offsets one slot twice, puts a slot outside the slots array, or lists its offsets out of slot order (§4.7). The only assertion that runs **before** `A00` — the last of those shapes makes the loader spin rather than return, so the round trip is refused instead of attempted |
3646
4052
  | `A32_EVENT_KEYS_RESOLVE` | both | an event key fires a name the skeleton's `events` block does not declare, sits earlier in time than the key before it, or sets `volume`/`balance` on an event with no `audio` (§4.8). **SKIP** when no animation carries an event timeline |
3647
4053
  | `A33_VERTEX_ATTACHMENT_GEOMETRY` | both | a bounding box, clipping polygon or path whose `vertexCount` is missing or disagrees with its vertex array, a weighted run that decodes to the wrong number of vertices or an out-of-range bone index, a clipping `end` naming a slot the skeleton does not have, a path whose vertex count is not a multiple of 3, or a path `lengths` array that does not strictly increase (§3.4). **SKIP** when the skeleton carries none of the three |
3648
- | `A34_CONSTRAINT_TIMELINE_TARGETS` | both | an `ik`, `transform`, `path` or `slider` timeline names a constraint the skeleton does not declare, names one of another type, or carries no keys at all (§4.9, §4.10, §4.12). The last is silent: the parser reads key 0, finds nothing, and skips the timeline. **SKIP** when no animation carries one |
4054
+ | `A34_CONSTRAINT_TIMELINE_TARGETS` | both | an `ik`, `transform`, `path`, `physics` or `slider` timeline names a constraint the skeleton does not declare, names one of another type, or carries no keys at all (§4.4, §4.9, §4.10, §4.12). The last is silent: the parser reads key 0, finds nothing, and skips the timeline. **SKIP** when no animation carries one |
3649
4055
  | `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` | both | a deform key's run runs past the end of the attachment's deform array, holds a non-finite number, has an empty key array, or names a skin/slot/attachment triple that does not resolve (§4.11). The overrun is the quiet one — the parser copies into a `Float32Array` and drops the tail. ⛔ It does **not** require pair alignment: the runtime has no such rule and a trimmed editor run legitimately starts and ends mid-pair (§4.11, issue #262). **SKIP** when no animation carries a deform timeline |
3650
4056
  | `A36_PATH_CONSTRAINT_EFFECTIVE` | both | a path constraint whose slot has no path attachment in any skin, one that constrains no bone, or one whose three mixes are all 0 at setup with no animation keying its `mix` (§3.5.1). The first is the quiet one: `update()` returns on its first line and the constraint reports mixes it never applies. **SKIP** when the skeleton declares no path constraint |
3651
4057
  | `A37_SLIDER_CONSTRAINT_EFFECTIVE` | both | a slider whose animation carries no timeline, one that loops a zero-length animation (the applied time is NaN), one driving off a bone at `scale: 0`, or one muted at setup with no animation keying its `mix` (§3.5.2). **SKIP** when the skeleton declares no slider |
3652
4058
  | `A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED` | both | a bone or constraint a skin activates that is not `skinRequired` (the list changes nothing), or one that is `skinRequired` and no skin activates (it is never active). Two keys in two places, and only together do they mean "this belongs to that skin" (§3.4.1). **SKIP** when no skin activates anything and nothing is `skinRequired` |
3653
- | `A39_DEFORM_KEEPS_TRIANGLE_WINDING` | archetype | a `deform` key reverses a triangle's winding, so the mesh has locally turned inside out and draws its texture backwards there (§4.11). The detail names the animation, the slot, the attachment, the key index and time, and each reversed triangle with its vertex triple and its signed area before and after. Measured at the key's **own** time, deformed against the same posed bones undeformed, so a mirrored slot bone cancels and a wrong *projection* with intact winding is correctly silent. A projection past its fold angle is the usual cause — [FACE.md §4.2](FACE.md) has the closed form. Legitimate art does fold, so declare `invariants.deformMayFold` (§3.7) for a slot that folds on purpose. ⚠️ A key whose slot **draws no pixels at that key's own time** — faded to alpha exactly 0, or showing another attachment — is measured and then passed over, because "draws its texture backwards" is false when nothing of it is drawn; the key is named on the stats line (`deformKeysNotDrawn`) and in the `DEFORM` block, never silently. The bar is **exactly 0**: at alpha 0.5 the fold is still refused and the alpha is in the message. It is per key and per time, so the same slot folding at full alpha in another animation is refused as before. ⚠️ And the **spans between** consecutive keys are scanned too (§4.11.3, issue #403): the runtime interpolates, so a deform inside its fold angle at every key can be past it in between. That refusal is its own sentence — `BETWEEN key 0 (t=0s) and key 1 (t=0.5s), at t=…` — with the time solved for in closed form and then posed and measured like any key, alpha read at that same moment. `deformSpansScanned` says on every green build that the scan ran. ⚠️ And the **frame** it poses in is the one the animation is reached in (§4.11.4, issue #407): on a track when nothing applies it, and otherwise once per **slider**, with that slider's mapping inverted and its bone driven until the runtime selects the key's own time — because a slider picks the time, so the two are one number and posing them independently is a frame that never occurs. The frame is on every `DEFORM` line, on the stats line as `deformFrames`, and in the refusal itself when it is not the track. A key at a time **no dial value selects** is measured in the frame the runtime does land on, left out of `deformKeysMeasured` and named as `deformKeysUnreachable`/`deformUnreachable` — never refused and never silent. **SKIP** when no animation carries a deform timeline, when nothing keyed has triangles, when every mesh keyed is exempt, when every key measured draws no pixels or is unreachable *and no span between them folds where anything is drawn*, or when there is no rig info at all |
4059
+ | `A39_DEFORM_KEEPS_TRIANGLE_WINDING` | archetype | a `deform` key reverses a triangle's winding, so the mesh has locally turned inside out and draws its texture backwards there (§4.11). The detail names the animation, the slot, the attachment, the key index and time, and each reversed triangle with its vertex triple and its signed area before and after. Measured at the key's **own** time, deformed against the same posed bones undeformed, so a mirrored slot bone cancels and a wrong *projection* with intact winding is correctly silent. A projection past its fold angle is the usual cause — [FACE.md §4.2](FACE.md) has the closed form. Legitimate art does fold, so declare `invariants.deformMayFold` (§3.7) for a slot that folds on purpose. ⚠️ A key whose slot **draws no pixels at that key's own time** — faded to alpha exactly 0, or showing another attachment — is measured and then passed over, because "draws its texture backwards" is false when nothing of it is drawn; the key is named on the stats line (`deformKeysNotDrawn`) and in the `DEFORM` block, never silently. The bar is **exactly 0**: at alpha 0.5 the fold is still refused and the alpha is in the message. It is per key and per time, so the same slot folding at full alpha in another animation is refused as before. ⚠️ And the **spans between** consecutive keys are scanned too (§4.11.3, issue #403): the runtime interpolates, so a deform inside its fold angle at every key can be past it in between. That refusal is its own sentence — `BETWEEN key 0 (t=0s) and key 1 (t=0.5s), at t=…` — with the time solved for in closed form and then posed and measured like any key, alpha read at that same moment. `deformSpansScanned` says on every green build that the scan ran. ⚠️ And the **frame** it poses in is the one the animation is reached in (§4.11.4, issue #407): on a track when nothing applies it, and otherwise once per **slider**, with that slider's mapping inverted and its bone driven until the runtime selects the key's own time — because a slider picks the time, so the two are one number and posing them independently is a frame that never occurs. The frame is on every `DEFORM` line, on the stats line as `deformFrames`, and in the refusal itself when it is not the track. A key at a time **no dial value selects** is measured in the frame the runtime does land on, left out of `deformKeysMeasured` and named as `deformKeysUnreachable`/`deformUnreachable` — never refused and never silent. ⚠️ And the **skin** it poses in is the one the timeline is keyed on (§4.11.5, issue #583), since a deform's address is a `skin / slot / attachment` triple: the pose wears that skin, which also switches on any `skin: true` bone or constraint it activates, and the "nothing is drawn" sentence names the skin it was read under. **SKIP** when no animation carries a deform timeline, when nothing keyed has triangles, when every mesh keyed is exempt, when every key measured draws no pixels or is unreachable *and no span between them folds where anything is drawn*, or when there is no rig info at all |
3654
4060
  | `A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET` | both | two or more sliders whose animations key the same timeline, where a later one is not `additive` — it writes that property outright at `mix: 1` and every earlier slider on it is dead (§3.5.2). Also fires when the shared timeline **cannot** be additive (a slot colour, an attachment swap, a draw order, a sequence), where `"additive": true` is not the fix and one of the two has to go. The detail names the bone or slot and the property, every slider keying it in `constraints` order with its flag, and which one wins today. Three shapes are deliberately not findings: a slider below `mix: 1` or with its `mix` keyed (the apply is then a lerp from the current pose, not an overwrite), two `skinRequired` sliders no skin activates together, and two sliders on different properties. **SKIP** when fewer than two sliders are at full authority; a PASS means two were compared |
3655
4061
  | `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first ([#540](https://github.com/firejune/rigc/issues/540)). The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
3656
4062
 
@@ -3661,19 +4067,30 @@ clauses are gated by profile.
3661
4067
 
3662
4068
  ## 6. What rigc will refuse — do not spend a loop on these
3663
4069
 
3664
- These are in the Spine 4.3 format, and the emitter does not write them. Each one is
3665
- a **`NotImplementedError` naming the field**, because the parser's own behaviour is
3666
- worse: an unknown attachment `type` returns `null` and the attachment disappears,
3667
- and a constraint entry with an unrecognised `type` matches no case and vanishes.
4070
+ Most of these are in the Spine 4.3 format and the emitter does not write them. Each
4071
+ of *those* is a **`NotImplementedError` naming the construct**, because the parser's
4072
+ own behaviour is worse: an unknown attachment `type` returns `null` and the
4073
+ attachment disappears, and a constraint entry with an unrecognised `type` matches no
4074
+ case and vanishes.
3668
4075
 
3669
- Each is deferred for a stated reason, and the reason is the same one in every row:
3670
- **neither of these types appears anywhere in the benchmark corpus** (SPEC_COVERAGE
4076
+ A deferral carries its reason, and the reason is the same one in every deferred row:
4077
+ **neither of those types appears anywhere in the benchmark corpus** (SPEC_COVERAGE
3671
4078
  parts 3-1 and 4-2), so neither is on the ladder's critical path. The message
3672
4079
  says so, because a deferral without its reason is a wall rather than a work item.
3673
4080
 
4081
+ ⚠️ **A spelling the format does not have is a different refusal and says so.**
4082
+ `sequence` is not an attachment type, and a `"type"` that is `null` is not an absent
4083
+ one — telling either author that "rigc does not emit it yet" promises work that will
4084
+ never be done, on a map the parser would have dropped in silence. Those rows below
4085
+ are `CompileError`s, and they name what the format actually defines
4086
+ ([#577](https://github.com/firejune/rigc/issues/577)).
4087
+
3674
4088
  | You wrote | You get |
3675
4089
  | --- | --- |
3676
- | attachment `type` of `point` or `linkedmesh` | `attachment type "X" is in the Spine 4.3 format and rigc does not emit it yet. Implemented: region, mesh, boundingbox, clipping, path. point and linkedmesh are deliberately deferred: neither appears anywhere in the benchmark corpus …` |
4090
+ | attachment `type` of `point` or `linkedmesh` | `this attachment is a "linkedmesh" — a mesh that takes its geometry from another mesh instead of stating any — a region/mesh head, then "source" …. rigc does not emit it yet, deliberately: it emits region, mesh, boundingbox, clipping, path, and neither a point nor a linked mesh appears anywhere in the benchmark corpus …` — the message names the **construct**, not just its type string, and part 1-6 is where the sentence comes from |
4091
+ | a mesh carrying `source` (`type: "mesh"` **or** `type: "linkedmesh"`) | the same refusal, prefixed `(a mesh carrying "source" is one)`. The two spellings share one parser branch and `source` is what decides between them (SPEC_COVERAGE part 1-6), so `source` on a mesh is a linked mesh whatever `type` says. It used to be refused as *2 keys this compiler does not read: "source", "skin" … fix the spelling or remove it*, whose remedy destroys the construct ([#577](https://github.com/firejune/rigc/issues/577)) |
4092
+ | attachment `type` of anything else — `sequence`, a typo | `attachment type "X" is not one of the 7 the Spine 4.3 format defines (region, mesh, linkedmesh, boundingbox, path, point, clipping). … the attachment is dropped from the skeleton without a word` — a **`CompileError`**, not a deferral: rigc is not going to implement a name the format does not have. (`sequence` is a key on a region or a mesh, not a type of its own.) |
4093
+ | `"type": null` | `"type" is null, which is not a name. … PRESENT-and-null is not absent: getValue(map, "type", "region") takes the default only when the key is missing, so this map matches no case, readAttachment returns null, and the attachment is dropped from the skeleton without a word. Remove the key, or name a type.` Leaving the key **out** is legal and reads as `region`; writing it as `null` is not the same thing ([#577](https://github.com/firejune/rigc/issues/577)) |
3677
4094
  | constraint `type` of anything else | `constraint type "X" is not one Spine 4.3 knows. The five are: ik, transform, path, physics, slider.` — all five are emitted, so this is a typo, and a typo is what the parser drops in silence |
3678
4095
  | a path attachment's `lengths` | `"lengths" is not authored — rigc measures the setup arc length of each curve off the geometry` (§3.4). Not a deferral: a second copy of a number the vertices already fix |
3679
4096
  | a `deform` timeline on a path attachment | `a path attachment does have a vertex array, and rigc does not key it yet` — the format allows it and an animated track is a real idiom, but a deformed path invalidates the `lengths` a `constantSpeed: false` traversal reads. Move the curve by posing the bones its vertices are bound to |
@@ -3702,19 +4119,35 @@ Two more limits that are not errors but will shape what you can attempt:
3702
4119
  1. `build --profile <the one you meant>` exits 0 and the report has **no FAIL**.
3703
4120
  Saying nothing means `spine`, so "the one you meant" is a decision either way —
3704
4121
  the report's first line names the profile that judged it.
4122
+ 📎 The two profiles judge attachment **naming** identically since
4123
+ [#574](https://github.com/firejune/rigc/issues/574): `spine-html` adds the
4124
+ renderer and archetype rules and has no opinion about how an attachment is
4125
+ spelled, so a rig whose named skins share a placeholder (§3.4.2) is green
4126
+ under either. Before that it was green under exactly one of them, and which
4127
+ one was not a property of the rig.
3705
4128
  2. Read the `SKIP` lines. Each one is a check that did *not* run — make sure none of
3706
- them is a check you were relying on.
4129
+ them is a check you were relying on. The summary's *measured* figure is the
4130
+ shortest version of this step: it is how many of the rules actually looked at
4131
+ anything, and it is the number to quote when you say a rig gated green.
3707
4132
  ⚠️ Under `--profile spine` a foreign skeleton usually produces **no SKIP lines
3708
4133
  at all**, and that is not a clean bill of health. The archetype assertions are
3709
4134
  excluded by the profile before the missing `invariants` block could make them
3710
4135
  skip, so they come back `PROF` instead. Do not go looking for a SKIP that the
3711
4136
  profile already accounted for; read step 3 instead.
4137
+ ⚠️ And a **red** report is where this step matters most, which is the opposite
4138
+ of how it reads: if `A00` failed, most of the SKIP lines say the round trip
4139
+ denied them a result, and the handful of `PASS` rows beside them were measured
4140
+ on the raw text alone. Step 1 already sent you back; do not take anything from
4141
+ the rest of that report on the way ([#568](https://github.com/firejune/rigc/issues/568)).
3712
4142
  3. Read the `PROF` lines. They are where "was this rig held to that rule at all"
3713
4143
  gets answered for everything the profile left out — the renderer policy *and*
3714
4144
  the archetype rules. A green under `spine` has been held to neither; a green
3715
4145
  under `spine-html` has been held to both.
3716
- 4. Run `explain` and read the slots table: every slot you declared should be there
3717
- (§3.3), in the order you meant, showing the setup attachment you meant.
4146
+ 4. Run `explain` and read the slots table: every slot you declared **is** there
4147
+ (§3.3 — `A26_SLOT_DRAW_ORDER` holds that, and a slot nothing fills reads
4148
+ `setup=null attachments=[]`), so what this step is for is the two things the
4149
+ gate cannot check — that the order is the one you meant, and that each slot
4150
+ shows the setup attachment you meant.
3718
4151
  5. If you were given **frames**, run `check` and read the table (§9). Steps 1–4 are
3719
4152
  all about validity and structure; none of them can tell you the animation is
3720
4153
  wrong, and this is the step that can. Do it before step 6, not after — `bench`
@@ -4196,10 +4629,56 @@ your animation is called something the frame directory is not, `--framing shared
4196
4629
  to fit one framing across every set instead of one each,
4197
4630
  `--texture-from <atlas>` to attribute how much of the MAE is texture resampling
4198
4631
  rather than the rig (**§9.2**'s atlas floor — and note that it is *not* `--atlas`,
4199
- which re-seats your geometry on that atlas's packing), `--all-frames` to list
4632
+ which re-seats your geometry on that atlas's packing), `--skin <name>` to pose
4633
+ your candidate under one of its skins (below), `--all-frames` to list
4200
4634
  every frame instead of the worst by MAE, `--json <out>` for the whole per-frame,
4201
4635
  per-slot report.
4202
4636
 
4637
+ 🚨 **What `check` certifies is the DEFAULT skin, unless you pass `--skin`**
4638
+ (issue #571). With no `--skin` no skin is set at all, which is `spine-core`'s own
4639
+ initial state: every slot resolves through `SkeletonData.defaultSkin` alone, and a
4640
+ slot whose art lives only in a named skin draws **nothing** — on both sides, since
4641
+ the reference frames came out of the same renderer. A multi-skin rig checked that
4642
+ way compares blank against blank and reports `MAE mean 0.00`, which reads like the
4643
+ best possible answer and is a measurement of no art at all. On a single-skin rig
4644
+ this is the whole rig and there is nothing to pass.
4645
+
4646
+ So a multi-skin rig is checked **once per skin**, and both sides are rendered
4647
+ under the same one:
4648
+
4649
+ ```bash
4650
+ bun cli.ts render --candidate path/to/spine --skin patch --out frames/patch
4651
+ bun cli.ts check --candidate path/to/spine --frames frames/patch --skin patch
4652
+ ```
4653
+
4654
+ Three things keep that honest, and none of them is a convention you have to
4655
+ remember:
4656
+
4657
+ - `render --skin` writes the name into `frames.json`, so a frame set says which
4658
+ picture of the rig it is. A set rendered with **no** `--skin` records nothing,
4659
+ because "no skin was set" and "this file predates the field" are the same bytes
4660
+ on disk and neither is a claim.
4661
+ - `check` reads that back. A candidate posed under a **different** skin from the
4662
+ one the frames record — or under none, where the frames name one — is
4663
+ **refused by name** rather than scored, because the number would be about the
4664
+ difference between two skins. Frames that record nothing cannot be checked, and
4665
+ the report says so in a `⚠️` note instead of implying agreement.
4666
+ - The report header names the skin on both sides on every run, `--skin` or not:
4667
+ `skin candidate patch frames patch`, or `candidate no skin set (the default
4668
+ skin alone)`. `check.json` carries the same two under `skin` and
4669
+ `referenceSkin`.
4670
+
4671
+ A skin name the candidate does not declare is refused with the ones it does —
4672
+ `the candidate declares no skin "path"; it declares [default, patch, torn]`.
4673
+
4674
+ 📌 **Deform measurement needs no such flag, because the timeline carries the
4675
+ name.** `A39` and the `DEFORM` block pose each key with the skin that key is keyed
4676
+ on — a deform timeline's address is a `skin / slot / attachment` triple — so a
4677
+ mesh in a named skin is measured in that skin without anything being passed on the
4678
+ command line (§4.11.5, issue #583). The difference is where the name comes from,
4679
+ not which command cares: `check` compares two picture sets and neither of them
4680
+ records a skin unless you say so, while a deform key knows its own.
4681
+
4203
4682
  ⭐ **A frame set may ship a contact sheet instead of every frame, and the sheet is
4204
4683
  compared too.** A long shot does not commit 311 near-duplicate PNGs: rung 2's sets
4205
4684
  ship `f0000.png` and `f0310.png` plus a `contact.png` holding all 311 sampled