spine-rigc 2.51.0 → 2.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE.md CHANGED
@@ -40,8 +40,8 @@ Software's terms, not a licence term of this project:
40
40
  development dependency for its own gate, and so does an install with
41
41
  `spine-core` added beside it. Validation is not optional on either: the gate
42
42
  runs through the runtime where it is installed and through rigc's own validator
43
- where it is not, and the two are held to the same verdicts in CI on 48 of the
44
- 49 assertions — the official parser's own parse runs only where the runtime is.
43
+ where it is not, and the two are held to the same verdicts in CI on 49 of the
44
+ 50 assertions — the official parser's own parse runs only where the runtime is.
45
45
 
46
46
  > **If a product integrates a Spine Runtime to play rigc's output, that integration
47
47
  > is subject to the terms above** — rigc neither creates those terms nor removes
package/README.md CHANGED
@@ -65,12 +65,12 @@ it cuts both ways:
65
65
  is a development dependency: this repository uses it to hold rigc's own validator
66
66
  to the round trip's verdicts — in CI, on the public example recipes and every
67
67
  selftest call, and on a private corpus of production rigs measured before each
68
- release. Of the 49 assertions, 48 run on both; `A00_ROUNDTRIP_PARSE`, the official
68
+ release. Of the 50 assertions, 49 run on both; `A00_ROUNDTRIP_PARSE`, the official
69
69
  parser's own parse, runs only where the runtime is installed and is reported as a
70
70
  SKIP elsewhere. There, in the parse's place, the report also carries the model
71
71
  side's own two parse rules, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`,
72
72
  which no round-trip rule matches and the registry does not hold — so its summary
73
- totals 51 on the core entry, and its last line counts 42 rules on the model side
73
+ totals 52 on the core entry, and its last line counts 42 rules on the model side
74
74
  ([AUTHORING §5.2](docs/AUTHORING.md) has a row for each). Install it beside rigc
75
75
  (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs the round
76
76
  trip on every build. The
@@ -618,9 +618,9 @@ first three work on any reference you have, and `bench` is a repository workflow
618
618
  and `bun run fetch-examples`. The reasoning behind them is in
619
619
  [the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
620
620
 
621
- `build` and `validate` both default to `--profile spine` — the 35 validity rules, which
621
+ `build` and `validate` both default to `--profile spine` — the 36 validity rules, which
622
622
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile rig-play`
623
- adds all 49: the other 14 are one renderer's policy and one canvas budget's, and they
623
+ adds all 50: the other 14 are one renderer's policy and one canvas budget's, and they
624
624
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
625
625
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
626
626
  yourself: author under `--profile rig-play` and read the extra 14 as findings, and
@@ -764,7 +764,12 @@ and so is a build with no atlas or an atlas that is a directory, or an
764
764
  `--exported` file that is not one JSON object.
765
765
 
766
766
  ⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
767
- editor, so no automated check runs it.
767
+ editor, so no automated check runs it. The by-hand measurement before a landing
768
+ trips the production rigs and one **parts-built** rig, the exam's task-01
769
+ painting through `@rig-c/parts`. The production rigs were authored in the editor,
770
+ so they are already in the editor's own shape. A refusal that only a generated
771
+ rig carries cannot show up on them. Issue #1407 was such a refusal: a green build
772
+ whose meshes listed their bones in two orders, which the editor would not import.
768
773
 
769
774
  ⚠️ Build with `--copy-images`. An ordinary build's atlas names its pages by a
770
775
  relative path back to the art directory, and the round trip copies that atlas to
@@ -782,7 +787,7 @@ letting `A17` blame the editor for the harness's own doing.
782
787
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
783
788
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
784
789
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | **the Spine 4.3 format, field by field.** Every key the 4.3 JSON parser and atlas reader take, what each defaults to, and the parser line it comes from — the rows rigc's refusals cite by part number. Ships in the package too |
785
- | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
790
+ | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 50 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
786
791
  | 📋 [LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/rig-c/core/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/rig-c/core/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
787
792
  | 🔬 [SURVEY_2026-08-22.md](https://github.com/rig-c/core/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
788
793
  | 🧬 [GENERATIONS.md](https://github.com/rig-c/core/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
@@ -832,7 +837,7 @@ quality."* All six, with their verdicts, are in
832
837
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
833
838
 
834
839
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
835
- see, every rung, the run viewer, the 49 assertions and the selftest behind them — is
840
+ see, every rung, the run viewer, the 50 assertions and the selftest behind them — is
836
841
  [docs/BENCHMARK.md](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
837
842
  Live rung status is
838
843
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -165,7 +165,7 @@ side over the document — its own two parse rules first, `A00_MODEL_READ` and
165
165
  `A00_MODEL_REGIONS_ON_PAGES`, which stand where spine-core's parse stands and are
166
166
  not in the registry, then the registry's rules that read the model — and the round
167
167
  trip's own rules restated over the text the emitter wrote (A01, A02, A05, A07, A16,
168
- A31, A35, and A18 over a second compile's three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
168
+ A31, A35, A52, and A18 over a second compile's three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
169
169
  naming spine-core. Its report ends on a line saying which rules ran there and
170
170
  which did not, and it writes only when none of them fails (§5.2).
171
171
 
@@ -239,7 +239,7 @@ What the flags mean:
239
239
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
240
240
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
241
241
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
242
- | `--profile` | `spine` = the 35 validity rules (**the default**) · `rig-play` = all 49, opt-in. `spine-html`, the profile's former name, is read as `rig-play` until the next minor release and prints a deprecation line on stderr naming `rig-play` |
242
+ | `--profile` | `spine` = the 36 validity rules (**the default**) · `rig-play` = all 50, opt-in. `spine-html`, the profile's former name, is read as `rig-play` until the next minor release and prints a deprecation line on stderr naming `rig-play` |
243
243
  | `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
244
244
  | `--candidate` | `check`, `bench`, `render`, `inspect`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
245
245
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
@@ -2252,6 +2252,22 @@ rigc therefore refuses a weighted `vertices` run unless the attachment says
2252
2252
  `"boneIndexing": "raw"` out loud — an opt-in, because what is being opted into is
2253
2253
  the silence.
2254
2254
 
2255
+ 🔢 **The order a vertex lists its bones in is free, until two vertices disagree.**
2256
+ The Spine editor refuses to import a weighted attachment whose vertices admit no
2257
+ single bone order — one vertex listing `link_a, link_b` and another
2258
+ `link_b, link_a`, three listing `a, b` · `b, c` · `c, a`, or one listing a bone
2259
+ twice — on a mesh, a bounding box, a clipping polygon and a path alike
2260
+ (`Error reading attachment: <name>` · `Index -2 out of bounds`), while spine-core
2261
+ reads every one of them. Weights written heaviest-first are exactly that shape. So
2262
+ `build` writes such an attachment with **every vertex's influences in ascending
2263
+ bone order** — in `skeleton.json` and in the model document, its `deform` runs
2264
+ moved with their influences (§4.11) — and leaves every other attachment in the
2265
+ order you gave it: one order per attachment is what the editor needs, and a
2266
+ descending one imports. `A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` (§5.2) holds
2267
+ the file to it. Nothing about the pose changes but the order a vertex's
2268
+ influences are summed in, which is measured at ≤ 2 ulp of a posed vertex and no
2269
+ pixel (issue #1407).
2270
+
2255
2271
  ⚠️ `vertices` carries **no encoding flag** of its own, which is why the length rule
2256
2272
  above is load-bearing: if `vertices.length` equals `uvs.length` the parser reads
2257
2273
  unweighted x/y pairs, otherwise it reads the weighted run. A coincidental length
@@ -5291,7 +5307,11 @@ back on load; on a weighted one zero *is* "unmoved".
5291
5307
  on a weighted one.** Its length is `vertices.length` in the first case and
5292
5308
  `vertices.length / 3 * 2` in the second, because a weighted attachment stores
5293
5309
  `x, y, weight` per influence. A vertex with three bones on it therefore occupies
5294
- **three** pairs, each in that bone's own bind space. This is the whole reason
5310
+ **three** pairs, each in that bone's own bind space, in the order the spec lists
5311
+ the vertex's bones. Write a run against that order: where `build` rewrites an
5312
+ attachment's bone order (§3.4 — vertices that admit no single order), it moves
5313
+ each run's pairs with their influences, writing a 0 where the moved run has a gap.
5314
+ This is the whole reason
5295
5315
  `fromVertex` exists and the whole reason it is sometimes refused:
5296
5316
 
5297
5317
  - on an **unweighted** attachment `fromVertex` always works — vertex *v* is array
@@ -6538,9 +6558,9 @@ prints six rows and is one failure here.
6538
6558
  `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES` — the first two rows of the
6539
6559
  table below — which stand where `A00_ROUNDTRIP_PARSE`'s parse stands and have no
6540
6560
  round-trip counterpart, so they are not in the registry. That is the whole of the
6541
- difference between the two totals: `<N>` is 51 on the core entry and 49 on `cli.ts build`,
6561
+ difference between the two totals: `<N>` is 52 on the core entry and 50 on `cli.ts build`,
6542
6562
  and the core report's last line states the split — 42 rules on the model side,
6543
- which are the 40 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6563
+ which are the 40 registry assertions the model side runs and those two; the 9 restated over the emitted text;
6544
6564
  and `A00_ROUNDTRIP_PARSE`, which it reports as a SKIP.
6545
6565
 
6546
6566
  🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
@@ -6604,6 +6624,7 @@ Fix A00 and run it again.
6604
6624
  | <a id="A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP"></a>`A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` | renderer | two regions on one page whose rectangles overlap **and** whose footprints overlap — so one drawing samples texels that are the other's. A region's **footprint** is what samples it: a region a mesh names (its `path`, else its name, every frame of a `sequence` included) draws the mesh's hull loop and its triangles, its UVs placed on the page as `MeshAttachment.computeUVs` places them (a **linked mesh** draws its source's); a region a region attachment names, or that nothing names, draws its whole rectangle — and so does a mesh region whose UVs leave 0..1, whose hull or triangles do not index its vertices, or that is turned by `rotate:`, and every region when a `sequence` cannot be read. For two rectangles that is exactly the clause `A06` held until issue #1099 — an editor alias, a sequence frame, two rectangles moved over the same texels are refused as they were — and it accepts what `--pack-shape polygon` writes: a neighbour inside a mesh's rectangle where the hull is not (§0.1). **Touching is not overlapping**: interiors are compared, every comparison `FOOTPRINT_SLACK` (1e-6 texels) in favour of apart. `regions "ear_l" (341,696 112x420) and "mouth" (332,709 96x48) overlap on page "skeleton.png", and so do their footprints — "ear_l" draws the hull of mesh "ear_l" (skin "default", slot "ear_l"), "mouth" draws its whole rectangle, which region attachment "mouth" (skin "default", slot "mouth") draws; what they draw overlaps by 1916.5512 texel(s) of area inside 347,709 81x48. One drawing samples texels that are the other's: move one region clear of the other's footprint, or pack the page again` — `gallery/nod`'s polygon pack with `mouth` moved onto a triangle of the `ear_l` mesh (`PK88`). Nothing about the **padding** is read — the atlas and the model document do not state it — and the `2 · padding` the packer keeps is the packer's own (`PK81`). A renderer rule because two regions over the same texels is what an editor's packer writes for an alias or a sequence: 49 pairs on four of the ten atlases in `examples/`. **SKIP** when the atlas declares no page or no region, and when no page carries two regions — every unpacked build |
6605
6625
  | <a id="A50_STAGE_BOX_IS_THE_STAGE"></a>`A50_STAGE_BOX_IS_THE_STAGE` | both | the stage box a rig asked for (`skeleton.stageBox`, §3.1) is not the stage the model document states. Read on the data a consumer reads and on the pose it reads: the slot carries, in the `default` skin under the asked name, an unweighted bounding box of four vertices whose **stored** vertices are the stage's corners exactly — `(x, y)`, `(x + w, y)`, `(x + w, y + h)`, `(x, y + h)`, as the float32 the runtime loads — and whose **world** vertices at the setup pose (constraints applied, no physics, no skin set) are those corners as the runtime poses them on an unrotated root, compared on the header's 1e-6 grid at float32. `the stage box (slot "stage", attachment "stage"): vertex 1 is stored as (65, 0) and the stage's corner is (64, 0) — the stage 0,0 64x48, bottom-left first and counter-clockwise`; a root moved at setup reads *vertex 0 poses at (1, 0) at the setup pose and the stage's corner poses at (0, 0) on an unrotated root, on the header's grid — something moves the box's bone at setup (a constraint, or a pose the root does not state)*; a slot the skeleton lacks, no attachment of that name, another type, or a weighted box are each named too. Through spine-core it reads `findSlot`, `defaultSkin.getAttachment` and `computeWorldVertices` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side the document's record and rigc's core poser, held to spine-core's poses at tolerance 0 (`PG06`, `PG07`). **SKIP** where nothing asks: *nothing asks for a stage box: no rigc-compiled/3 model document beside this skeleton states one …* — a rig without the field, and any skeleton with no `/3` document beside it, an export among them |
6606
6626
  | <a id="A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS"></a>`A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` | both | a bone the rig holds to a line (`invariants.axes`, §3.7) whose world x axis at the setup pose — constraints applied, no physics, no skin set, the pose `A50` reads — is more than the entry's `tolerance` off the line through its two `through` bones' world origins, compared as lines (0° to 90°): `axis "face" (through "eye_r"→"eye_l", 30.0000°): bone "brow_l" world x axis at 36.0000°, 6.0000° off, tolerance 5° — rotate the bone in the rig spec onto the line and counter-rotate its attachments so the setup pose stays put`. Every angle is Spine world degrees (y up), counter-clockwise from +x. **Fix it in the rig spec**: set the bone's `rotation` so its world x axis lands on the axis angle the line prints, counter-rotate its attachments by the same angle so the setup pose does not move, and restate its children in its new frame. Also fails, by the entry's name: two `through` bones on one world origin (*share the world origin (x, y) at the setup pose, so they draw no line* — move one, or name another pair); a bone with no world x axis (a scale of 0); a bone inactive with no skin set (*so the setup pose places it nowhere*); a bone the skeleton does not have. Held whether or not an animation keys the bone: a consumer writes it at run time. Through spine-core it reads `appliedPose` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side rigc's core poser, held to spine-core's poses at tolerance 0 (`AX01`–`AX05`). **SKIP** when the rig declares no axes — *the rig "X" declares no axes (invariants.axes)* — and on a bare directory with no rig |
6627
+ | <a id="A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER"></a>`A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` | both | a weighted mesh, bounding box, clipping polygon or path whose vertices list their bones in no single order — `mesh "default/hair/hair": no one bone order fits its vertices — vertex 0 lists bone 11 before 18 (its bones: 11, 18), vertex 9 lists bone 18 before 11 (its bones: 18, 11)`, or three vertices in a cycle (`a` before `b`, `b` before `c`, `c` before `a`), or one vertex that lists a bone twice. The numbers are bone indices in the skeleton's `bones` array, and the vertex numbers count from 0 in the run. spine-core loads all of these; the Spine editor refuses the import (`Error reading attachment: <name>` · `Index -2 out of bounds`, measured on 4.3.26 on all four types), so this is the one place the round trip cannot see the fault. A descending order, or any order shared by every vertex, is accepted: four of the twelve editor exports in the example corpus are out of ascending order and pass. **A `build` never fails it** — the compiler writes an attachment with no single order in ascending bone order (§3.4); it fires on a Spine file written or edited by something else. **Fix it in the file**: list every vertex's `(index, x, y, weight)` tuples in one order — ascending index is one — and move each deform key's pairs with them; or rebuild through `rigc build`. **SKIP** when the skeleton has no weighted vertex attachment. Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
6607
6628
 
6608
6629
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
6609
6630
  clauses are gated by profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.51.0",
3
+ "version": "2.51.1",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A52 (issue #1407): every weighted vertex attachment lists its bones in one
3
+ * order — the order the Spine editor needs and spine-core does not. Run over
4
+ * the skeleton JSON by both entries: `validate()` in `src/validate.ts` over
5
+ * the text it hands spine-core, and the entry that links none of the runtime
6
+ * over the text rigc wrote (`../emitted/index.ts`), so the selftest's `RC28`
7
+ * holds the two to the same lines on every recipe and on this assertion's
8
+ * mutant.
9
+ *
10
+ * ⭐ **The rule is a cycle, measured, and not "ascending".** With Spine 4.3.26
11
+ * Professional (`tools/editor_roundtrip.ts`), the import refuses an attachment
12
+ * when one vertex lists bones `a, b` and another `b, a`, when three vertices
13
+ * list `a, b` · `b, c` · `c, a` with no pair reversed, and when one vertex
14
+ * lists a bone twice — `Error reading attachment: <name>` · `Index -2 out of
15
+ * bounds` — on a mesh, a bounding box, a clipping polygon and a path alike.
16
+ * The same rig with every vertex in DESCENDING index order imports, exports,
17
+ * validates and checks; and four of the twelve editor exports in the example
18
+ * corpus list bones out of ascending order, each in one order per attachment.
19
+ * So "strictly ascending" would refuse Esoteric's own files, and this refuses
20
+ * exactly the shape the editor refuses. spine-core reads every one of these
21
+ * without a word, which is why it is an assertion: the round trip passes it.
22
+ *
23
+ * Re-derived from the emitted indices on purpose, with nothing shared with
24
+ * `compile.ts`, which makes the build's order hold this — the gate does not
25
+ * check the compiler with the compiler's code.
26
+ *
27
+ * Links nothing from the runtime.
28
+ */
29
+ import type { Verdicts } from '../harness.ts';
30
+ import { isObj, type Json } from '../values.ts';
31
+
32
+ const CODE = 'A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER';
33
+
34
+ /** The four attachment types a weighted vertex run can belong to. */
35
+ const VERTEX_TYPES = new Set(['mesh', 'boundingbox', 'clipping', 'path']);
36
+
37
+ /** One vertex's bone indices, in the order the run lists them. */
38
+ type Listed = { vertex: number; bones: number[] };
39
+
40
+ /**
41
+ * A weighted run's vertices, each with the bone indices it lists — or `null`
42
+ * when the attachment is unweighted or its run does not decode (A04, A20 and
43
+ * A33 own that shape; this measures order, not encoding).
44
+ */
45
+ function weightedVertices(att: Json): Listed[] | null {
46
+ const type = typeof att.type === 'string' ? att.type : 'region';
47
+ if (!VERTEX_TYPES.has(type) || !Array.isArray(att.vertices)) return null;
48
+ const length = type === 'mesh' ? (Array.isArray(att.uvs) ? att.uvs.length : null) : typeof att.vertexCount === 'number' ? att.vertexCount * 2 : null;
49
+ const run = att.vertices as unknown[];
50
+ if (length === null || run.length === length) return null;
51
+ const out: Listed[] = [];
52
+ for (let i = 0; i < run.length; ) {
53
+ const n = run[i++];
54
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || i + n * 4 > run.length) return null;
55
+ const bones: number[] = [];
56
+ for (let k = 0; k < n; k++) {
57
+ const bone = run[i + k * 4];
58
+ if (typeof bone !== 'number') return null;
59
+ bones.push(bone);
60
+ }
61
+ out.push({ vertex: out.length, bones });
62
+ i += n * 4;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** `vertex 3 lists bone 18 before 11 (its bones: 18, 11)`. */
68
+ function step(listed: Listed, from: number, to: number): string {
69
+ return `vertex ${listed.vertex} lists bone ${from} before ${to} (its bones: ${listed.bones.join(', ')})`;
70
+ }
71
+
72
+ /**
73
+ * Why no one order fits these vertices, in the vertices' own words — or
74
+ * `null` when one does. A vertex naming a bone twice is its own reason; a
75
+ * cycle is found by a depth-first walk over "listed directly before", in
76
+ * the order the run first lists each bone, and every edge of it is named by
77
+ * the first vertex that lists it.
78
+ */
79
+ function noOneOrder(vertices: Listed[]): string | null {
80
+ for (const listed of vertices) {
81
+ const twice = listed.bones.find((bone, k) => listed.bones.indexOf(bone) !== k);
82
+ if (twice !== undefined) return `vertex ${listed.vertex} lists bone ${twice} twice (its bones: ${listed.bones.join(', ')})`;
83
+ }
84
+ /** bone -> the bones listed directly after it, each with the first vertex that does. */
85
+ const after = new Map<number, Map<number, Listed>>();
86
+ for (const listed of vertices) {
87
+ for (let k = 1; k < listed.bones.length; k++) {
88
+ const from = listed.bones[k - 1];
89
+ if (!after.has(from)) after.set(from, new Map());
90
+ const next = after.get(from)!;
91
+ if (!next.has(listed.bones[k])) next.set(listed.bones[k], listed);
92
+ }
93
+ }
94
+ const state = new Map<number, 'open' | 'done'>();
95
+ const path: number[] = [];
96
+ const walk = (bone: number): number[] | null => {
97
+ state.set(bone, 'open');
98
+ path.push(bone);
99
+ for (const next of after.get(bone)?.keys() ?? []) {
100
+ if (state.get(next) === 'open') return [...path.slice(path.indexOf(next)), next];
101
+ if (!state.has(next)) {
102
+ const cycle = walk(next);
103
+ if (cycle !== null) return cycle;
104
+ }
105
+ }
106
+ path.pop();
107
+ state.set(bone, 'done');
108
+ return null;
109
+ };
110
+ for (const bone of after.keys()) {
111
+ if (state.has(bone)) continue;
112
+ const cycle = walk(bone);
113
+ if (cycle === null) continue;
114
+ const steps: string[] = [];
115
+ for (let k = 1; k < cycle.length; k++) steps.push(step(after.get(cycle[k - 1])!.get(cycle[k])!, cycle[k - 1], cycle[k]));
116
+ return steps.join(', ');
117
+ }
118
+ return null;
119
+ }
120
+
121
+ export function a52WeightedVerticesShareOneBoneOrder({ fail, skip }: Verdicts, raw: Json | null): void {
122
+ if (!raw) return skip(CODE, 'the skeleton JSON did not parse (A00 owns that failure)');
123
+ let measured = 0;
124
+ for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
125
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
126
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
127
+ for (const [slot, perSlot] of Object.entries(skin.attachments)) {
128
+ if (!isObj(perSlot)) continue;
129
+ for (const [name, att] of Object.entries(perSlot)) {
130
+ if (!isObj(att)) continue;
131
+ const vertices = weightedVertices(att);
132
+ if (vertices === null) continue;
133
+ measured++;
134
+ const why = noOneOrder(vertices);
135
+ if (why === null) continue;
136
+ fail(
137
+ CODE,
138
+ `${String(att.type)} "${skinName}/${slot}/${name}": no one bone order fits its vertices — ${why}. ` +
139
+ 'spine-core reads it; the Spine editor refuses the import ("Index -2 out of bounds"). ' +
140
+ 'List every vertex\'s bones in one order — ascending bone index is one, and the order `rigc build` writes',
141
+ );
142
+ }
143
+ }
144
+ }
145
+ if (measured === 0) return skip(CODE, 'no weighted vertex attachment to measure');
146
+ }
@@ -3,14 +3,15 @@
3
3
  * step 4e of #380) — the half of the gate the entry that links none of
4
4
  * spine-core runs beside the model side (`../model/index.ts`).
5
5
  *
6
- * ⭐ **Every one of the 50 is accounted for, by name.** The model side runs
6
+ * ⭐ **Every assertion in the registry is accounted for, by name.** The model side runs
7
7
  * the moved assertions (`MOVED_ASSERTIONS`) over the document. What is left —
8
8
  * the complement, read off `ASSERTION_KIND` rather than listed — is the
9
- * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35 at the time of
9
+ * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35, A52 at the time of
10
10
  * writing. Each of those but one reads the raw text, not what spine-core
11
11
  * loaded: the atlas's line layout (A07), the skeleton JSON's draw-order
12
- * offsets, deform runs, version label, legacy arrays, legacy bone key and
13
- * curve arrays (A31, A35, A16, A01, A02, A05), and A18's second compile. So
12
+ * offsets, deform runs, weighted runs' bone order, version label, legacy arrays,
13
+ * legacy bone key and curve arrays (A31, A35, A52, A16, A01, A02, A05), and A18's
14
+ * second compile. So
14
15
  * each is restated here over the same text, by a body under `../bodies/` that
15
16
  * is the round trip's clause word for word, and prints the round trip's line
16
17
  * (`RC28` holds the two on every recipe and on each assertion's mutants).
@@ -35,6 +36,7 @@ import { a16SkeletonVersion43 } from '../bodies/a16.ts';
35
36
  import { a18DeterministicEmit } from '../bodies/a18.ts';
36
37
  import { a31DrawOrderOffsetsResolve } from '../bodies/a31.ts';
37
38
  import { a35DeformKeysFitTheAttachment } from '../bodies/a35.ts';
39
+ import { a52WeightedVerticesShareOneBoneOrder } from '../bodies/a52.ts';
38
40
  import { parseAtlasText } from '../../atlas.ts';
39
41
 
40
42
  /** What the restated rules read: the emitted texts, the document written beside them, and a second compile's three. */
@@ -72,6 +74,7 @@ export const EMITTED_TEXT_RULES: ReadonlyArray<{ code: string; run: (v: Verdicts
72
74
  { code: 'A07_ATLAS_TEXT_SHAPE', run: (v, t) => a07AtlasTextShape(v, t.input.atlasText) },
73
75
  { code: 'A31_DRAW_ORDER_OFFSETS_RESOLVE', run: (v, t) => a31DrawOrderOffsetsResolve(v, t.raw) },
74
76
  { code: 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', run: (v, t) => a35DeformKeysFitTheAttachment(v, t.raw) },
77
+ { code: 'A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER', run: (v, t) => a52WeightedVerticesShareOneBoneOrder(v, t.raw) },
75
78
  { code: A00, run: (v) => v.skip(A00, SKIP_NO_ROUND_TRIP) },
76
79
  { code: 'A16_SKELETON_VERSION_4_3', run: (v, t) => a16SkeletonVersion43(v, t.raw) },
77
80
  { code: 'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', run: (v, t) => a01NoLegacyToplevelConstraintArrays(v, t.raw) },
@@ -128,6 +128,7 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
128
128
  A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP: 'renderer',
129
129
  A50_STAGE_BOX_IS_THE_STAGE: 'validity',
130
130
  A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS: 'validity',
131
+ A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER: 'validity',
131
132
  };
132
133
 
133
134
  /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `rig-play` every kind. */
package/src/cli/shared.ts CHANGED
@@ -1337,7 +1337,7 @@ export const COMMANDS: CommandDoc[] = [
1337
1337
  'trip through spine-core, which this entry links none of. What runs instead: the model',
1338
1338
  'side over the document (every assertion moved off the round trip), and the round',
1339
1339
  'trip\'s own rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,',
1340
- 'A31, A35, and A18 over a second, independent compile\'s skeleton, atlas and document.',
1340
+ 'A31, A35, A52, and A18 over a second, independent compile\'s skeleton, atlas and document.',
1341
1341
  'What does not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming',
1342
1342
  'spine-core. The report\'s last line says which ran here and which did not. A00 runs',
1343
1343
  'in build and validate on the entry that links spine-core: installed, the same `rigc`',
package/src/compile.ts CHANGED
@@ -162,6 +162,7 @@ import {
162
162
  type ModelSlot,
163
163
  type ModelStage,
164
164
  type ModelTimelines,
165
+ type ModelVertexAttachment,
165
166
  type ModelVertices,
166
167
  type SkinTable,
167
168
  type SkinTableEntry,
@@ -3695,6 +3696,10 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3695
3696
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
3696
3697
  }
3697
3698
 
3699
+ // One bone order per weighted attachment (issue #1407), on the finished
3700
+ // model, so the document, both posers and the Spine file read one order.
3701
+ oneBoneOrderPerAttachment(bones, skins, animations, deformTransforms);
3702
+
3698
3703
  // The fields the model carries as `CompileResult` carries them, by reference:
3699
3704
  // one object, so the result and its model cannot say two different things.
3700
3705
  const carried: CarriedFromCompileResult = {
@@ -4451,6 +4456,142 @@ function bindRawRun(run: readonly number[], bones: readonly ModelBone[], where:
4451
4456
  return out;
4452
4457
  }
4453
4458
 
4459
+ /**
4460
+ * Whether one bone order fits every vertex of a weighted attachment: no vertex
4461
+ * lists a bone twice, and no two vertices — directly or through a chain of
4462
+ * others — list two bones in opposite orders. Read off the bindings by name;
4463
+ * `A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` re-derives the same question
4464
+ * from the emitted indices, on purpose with its own code.
4465
+ */
4466
+ function bindingsShareOneBoneOrder(bindings: readonly ModelBinding[][]): boolean {
4467
+ const after = new Map<string, Set<string>>();
4468
+ for (const vertex of bindings) {
4469
+ if (new Set(vertex.map((b) => b.bone)).size !== vertex.length) return false;
4470
+ for (let i = 1; i < vertex.length; i++) {
4471
+ const from = vertex[i - 1].bone;
4472
+ if (!after.has(from)) after.set(from, new Set());
4473
+ after.get(from)!.add(vertex[i].bone);
4474
+ }
4475
+ }
4476
+ const state = new Map<string, 'open' | 'done'>();
4477
+ const acyclicFrom = (bone: string): boolean => {
4478
+ state.set(bone, 'open');
4479
+ for (const next of after.get(bone) ?? []) {
4480
+ const seen = state.get(next);
4481
+ if (seen === 'open') return false;
4482
+ if (seen === undefined && !acyclicFrom(next)) return false;
4483
+ }
4484
+ state.set(bone, 'done');
4485
+ return true;
4486
+ };
4487
+ for (const bone of after.keys()) if (!state.has(bone) && !acyclicFrom(bone)) return false;
4488
+ return true;
4489
+ }
4490
+
4491
+ /**
4492
+ * Write every weighted attachment whose vertices admit no single bone order
4493
+ * with each vertex's influences in ascending bone order (issue #1407) — the
4494
+ * one rewrite of a spec's binding order the compiler makes, and the reason it
4495
+ * makes it.
4496
+ *
4497
+ * ⭐ **What the Spine editor refuses is a cycle, not a descending pair.**
4498
+ * Measured with Spine 4.3.26 Professional through `tools/editor_roundtrip.ts`:
4499
+ * an attachment where one vertex lists bones `a, b` and another `b, a` — or
4500
+ * three vertices `a, b` · `b, c` · `c, a`, or one vertex `a, a` — is refused
4501
+ * on import (`Error reading attachment: <name>` · `Index -2 out of bounds`),
4502
+ * on a mesh, a bounding box, a clipping polygon and a path alike; the same
4503
+ * rig with every vertex in DESCENDING order imports, exports and checks, and
4504
+ * four of the twelve editor exports in this tree's example corpus list bones
4505
+ * out of ascending order, every one of them in a single order per attachment.
4506
+ * So an attachment that already has one order keeps the spec's, and its bytes
4507
+ * do not move; one that has none takes ascending order, which every vertex
4508
+ * shares by construction.
4509
+ *
4510
+ * 🔒 **On the model, not in the emitter.** The document, rigc's poser and the
4511
+ * Spine file must read one order: a weighted vertex is `Σ wᵢ·Mᵢ·bindᵢ`, and
4512
+ * summed in two orders its last bits differ — sorting in the emitter alone
4513
+ * was measured to put the two posers 1–2 ulp apart on a parts-built rig.
4514
+ *
4515
+ * 🔸 A deform key on such an attachment is one `x, y` per INFLUENCE, in the
4516
+ * influences' order, so each key's run is moved with them: spread over the
4517
+ * deform array, permuted pair by pair, and cut back to the first and last
4518
+ * index it covers, an index it does not cover written as the 0 the runtime
4519
+ * reads there. The report of a generated run (`expanded`) is moved the same
4520
+ * way, so it still carries what was emitted.
4521
+ */
4522
+ function oneBoneOrderPerAttachment(
4523
+ bones: readonly ModelBone[],
4524
+ skins: readonly ModelSkin[],
4525
+ animations: ReadonlyMap<string, CompiledAnimation>,
4526
+ deformTransforms: CompileResult['deformTransforms'],
4527
+ ): void {
4528
+ const position = new Map(bones.map((bone, i) => [bone.name, i] as const));
4529
+ /** Per reordered attachment record: where each influence went, old influence index -> new. */
4530
+ const moved = new Map<ModelVertexAttachment, number[]>();
4531
+ for (const skin of skins) {
4532
+ for (const perSlot of Object.values(skin.attachments)) {
4533
+ for (const record of Object.values(perSlot)) {
4534
+ if (!isModelVertexAttachment(record) || !record.vertices.weighted || moved.has(record)) continue;
4535
+ if (bindingsShareOneBoneOrder(record.vertices.bindings)) continue;
4536
+ const to: number[] = [];
4537
+ let at = 0;
4538
+ const bindings = record.vertices.bindings.map((vertex) => {
4539
+ const order = vertex.map((_, i) => i).sort((p, q) => position.get(vertex[p].bone)! - position.get(vertex[q].bone)! || p - q);
4540
+ const rank = new Array<number>(vertex.length);
4541
+ order.forEach((from, k) => (rank[from] = k));
4542
+ for (let i = 0; i < vertex.length; i++) to.push(at + rank[i]);
4543
+ at += vertex.length;
4544
+ return order.map((i) => vertex[i]);
4545
+ });
4546
+ record.vertices = { weighted: true, bindings };
4547
+ moved.set(record, to);
4548
+ }
4549
+ }
4550
+ }
4551
+ if (moved.size === 0) return;
4552
+ /** A run moved with its influences: `start` and `run` over a deform array of `to.length` pairs. */
4553
+ const movedRun = (start: number, run: readonly number[], to: readonly number[]): { start: number; run: number[] } => {
4554
+ const spread = new Map<number, number>();
4555
+ run.forEach((value, i) => {
4556
+ const index = start + i;
4557
+ spread.set(to[index >> 1] * 2 + (index & 1), value);
4558
+ });
4559
+ const indices = [...spread.keys()];
4560
+ const first = Math.min(...indices);
4561
+ const last = Math.max(...indices);
4562
+ const out: number[] = [];
4563
+ for (let i = first; i <= last; i++) out.push(spread.get(i) ?? 0);
4564
+ return { start: first, run: out };
4565
+ };
4566
+ const recordOf = (skin: string, slot: string, placeholder: string): ModelVertexAttachment | undefined => {
4567
+ const record = skins.find((s) => s.name === skin)?.attachments[slot]?.[placeholder];
4568
+ return record !== undefined && isModelVertexAttachment(record) && moved.has(record) ? record : undefined;
4569
+ };
4570
+ for (const animation of animations.values()) {
4571
+ for (const [skin, bySlot] of animation.attachments) {
4572
+ for (const [slot, byPlaceholder] of bySlot) {
4573
+ for (const [placeholder, timelines] of byPlaceholder) {
4574
+ const record = recordOf(skin, slot, placeholder);
4575
+ if (record === undefined || timelines.deform === undefined) continue;
4576
+ const to = moved.get(record)!;
4577
+ timelines.deform = timelines.deform.map((key) => {
4578
+ if (!Array.isArray(key.vertices) || key.vertices.length === 0) return key;
4579
+ const { start, run } = movedRun(typeof key.offset === 'number' ? key.offset : 0, key.vertices as number[], to);
4580
+ // The key's fields in the order `compileDeformTrack` inserts them: `time`, `offset`, `vertices`, then the curve.
4581
+ const rest = Object.fromEntries(Object.entries(key).filter(([field]) => field !== 'time' && field !== 'offset' && field !== 'vertices'));
4582
+ return { time: key.time, ...(start !== 0 ? { offset: start } : {}), vertices: run, ...rest };
4583
+ });
4584
+ }
4585
+ }
4586
+ }
4587
+ }
4588
+ for (const report of deformTransforms) {
4589
+ const record = recordOf(report.skin, report.slot, report.attachment);
4590
+ if (record === undefined || report.expanded === undefined) continue;
4591
+ report.expanded = movedRun(0, report.expanded, moved.get(record)!).run;
4592
+ }
4593
+ }
4594
+
4454
4595
  /** Every number of a weighted binding list on the float32 grid — what `.map(f32)` did to the run. */
4455
4596
  function bindingsOnF32(vertices: Extract<ModelVertices, { weighted: true }>): Extract<ModelVertices, { weighted: true }> {
4456
4597
  return {
package/src/validate.ts CHANGED
@@ -156,6 +156,7 @@ import type { ConstraintTargetFacts, TargetAnimation, TargetKeyArray } from './a
156
156
  import { a34ConstraintTimelineTargets } from './assertions/bodies/a34.ts';
157
157
  import { a40SlidersComposeOnASharedTarget } from './assertions/bodies/a40.ts';
158
158
  import { a10NoNanAfterStepping } from './assertions/bodies/a10.ts';
159
+ import { a52WeightedVerticesShareOneBoneOrder } from './assertions/bodies/a52.ts';
159
160
  import type { SteppedFrame, SteppedPoseFacts } from './assertions/facts/stepped_poses.ts';
160
161
 
161
162
  export type { Failure } from './assertions/harness.ts';
@@ -2460,6 +2461,11 @@ export function validate(input: ValidateInput): ValidateReport {
2460
2461
  }
2461
2462
  });
2462
2463
 
2464
+ // A52 (issue #1407) reads the raw text as A31 and A35 do: the order a weighted
2465
+ // run lists its bones in is a fact of the file the Spine editor refuses and
2466
+ // spine-core loads, so it runs whether or not the round trip does.
2467
+ check('A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER', () => a52WeightedVerticesShareOneBoneOrder(verdicts, raw));
2468
+
2463
2469
  // --- A: the round trip ----------------------------------------------------
2464
2470
  // The two loaded objects come back OUT of the assertion rather than being
2465
2471
  // assigned into it. Everything below reads them, and a `let` written inside a