spine-rigc 0.25.4 → 0.25.6

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
@@ -56,14 +56,18 @@ provenance for a reader of record — the loop that hit a trap, the issue that c
56
56
  and following one can arrive at a stored candidate's own spec, at the corpus inventory,
57
57
  or at the **derivation** of the gate a verdict is read against, none of which a run may
58
58
  open. ⭐ The gate's **clause statements** are a different matter and a run may read them:
59
- they are in `docs/GATE.md`, item 11 of the allowed list (owner ruling 2026-08-29) — the
59
+ they are in
60
+ [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md), which is
61
+ repository material and not in the published package — item 11 of the allowed list (owner ruling 2026-08-29) — the
60
62
  measure, the comparator, the number and the SKIP semantics, with no recorded figure in it.
61
63
  So: read the
62
64
  document, take its numbered sections as the input, and leave its footprints to whoever
63
65
  is maintaining it. The rule this states is that an **allowed-reading surface has to be
64
66
  closed under reading**; the criterion behind it is under *The honesty rule* in
65
67
  [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md), and the enumerated allowed and forbidden lists are in
66
- `bench/runs/README.md`, *What a run may read* — the prompt that starts a run quotes them
68
+ [bench/runs/README.md](https://github.com/firejune/rigc/blob/main/bench/runs/README.md),
69
+ which is also repository material and not in the published package,
70
+ *What a run may read* — the prompt that starts a run quotes them
67
71
  outright, which is the copy that binds.
68
72
 
69
73
  ## The vocabulary is Spine's
@@ -156,13 +160,15 @@ What the flags mean:
156
160
  | `--rig` | the rig spec — skeleton structure |
157
161
  | `--motion` | the motion spec — time |
158
162
  | `--out` | directory for `skeleton.json` + `skeleton.atlas`; atlas page paths and `skeleton.images` are written relative to it |
159
- | `--copy-images` | `build` only: also copies every referenced page PNG into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (issue #370; §3.1 says why it is spelled `../<out>/` and not `./`). Default is unchanged — page paths still point at the source art (issue #217) |
163
+ | `--copy-images` | `build` only: also copies every page **the emitted atlas names** into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (issue #370; §3.1 says why it is spelled `../<out>/` and not `./`). Under `--atlas-in` those pages are the pack's, not one per part (issue #693 — **§0.2**). Default is unchanged — page paths still point at the source art (issue #217) |
160
164
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. Default is unchanged (issue #4) — **§0.1** |
161
165
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
162
166
  | `--padding` | `build --pack` only: the gutter each region reserves on every side (default `2`), filled by extending the region's own edge pixels outwards. `0` is not a legal-but-tight choice, it is bleed — **§0.1** |
163
- | `--atlas-in` | `build` only: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; the atlas is re-emitted into `--out`, re-anchored — **§0.2** |
167
+ | `--atlas-in` | `build` and `explain`: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; `build` re-emits the atlas into `--out`, re-anchored, and `explain` writes nothing and poses through it — **§0.2**. On `explain` it is the flag that makes a **size-only** spec readable at all (`ingest --art none`), because posing resolves every attachment against an atlas; without it that pair is refused by name rather than thrown through ([#697](https://github.com/firejune/rigc/issues/697), §5.1) |
164
168
  | `--images` | where the rig spec's `image` names resolve (overrides the rig's own `images` field, and is relative to your working directory). For `pose` it is the directory of **loose part PNGs to place** — every `.png` in it is a part, in name order. For `chainfit` it is only where each attachment's image name **resolves**: the candidate decides what the parts are, so extra PNGs are unused and a missing name is refused by name (§12.3) |
165
169
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
170
+ | `--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 |
171
+ | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
166
172
  | `--profile` | `spine` = the 28 validity rules (**the default**) · `spine-html` = all 43, opt-in |
167
173
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
168
174
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, 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`, so is a name that only *some* candidates have |
@@ -231,7 +237,8 @@ a gap:
231
237
 
232
238
  - **no resampling, no scaling.** A region's pixels are the loose PNG's pixels.
233
239
  rigc writes no `scale:` line, so a pack cannot be coarser than its drawings the
234
- way the shipped examples' packs are (§9.4).
240
+ way the shipped examples' packs are — what a `scale:` costs on the way back in
241
+ is §0.2.
235
242
  - **no trimming.** `offsets:` always states a zero inset and the drawing's full
236
243
  size. Stripping transparent border would shrink pages, and it would also make
237
244
  every region attachment's quad depend on the packer — measure it yourself if you
@@ -312,6 +319,17 @@ texel count beside it so both numbers are visible:
312
319
  measures the PNG. Reach for `--atlas-in` when the pack is what you were handed, or
313
320
  when drawing through the pack's own texels is the point.
314
321
 
322
+ **What `--out` holds afterwards:** `skeleton.json` and a `skeleton.atlas` that is
323
+ the pack, page paths pointing back at the pack's own PNGs — so `rigc validate
324
+ <that directory>` reads it green with no flags, exactly as it reads a loose
325
+ build's. Add `--copy-images` and the pack's page PNGs are copied in beside the
326
+ skeleton and the page names become their basenames, which is the same directory
327
+ with nothing outside it left to resolve. ⚠️ Until
328
+ [#693](https://github.com/firejune/rigc/issues/693) that flag rebuilt the atlas
329
+ from the parts the rig declared instead of from the pack, and a rebuild through
330
+ `ingest --art none` declares none: the file written was **zero bytes**, on a build
331
+ that printed `PASS` for all four atlas assertions.
332
+
315
333
  The emitted `skeleton.atlas` **is** the imported one, verbatim except for its page
316
334
  name lines, which are paths and have to be re-anchored to `--out`. Fields rigc
317
335
  does not re-serialise (`format:`, `repeat:`, and `scale:` itself) survive the trip
@@ -335,6 +353,28 @@ One limit, stated rather than discovered:
335
353
  pack is a `DROP`, not a refusal — the same documented absence a missing PNG has
336
354
  always been, and the line names the atlas rather than a file nobody opened.
337
355
 
356
+ ⭐ **The missing PNG it is the same rule as, spelled out**, because it was left
357
+ implicit here and the two halves are read together. A `states:` entry whose PNG is
358
+ not on disk is a `DROP` too, and the line names the file rather than the pack:
359
+
360
+ ```bash
361
+ # --atlas-in: DROP lens_l/shut: no region "lens_l_shut" in export/atlas.atlas (state not emitted)
362
+ # loose PNGs: DROP lens_l/shut: no PNG at parts/lens_l_shut.png (state not emitted)
363
+ ```
364
+
365
+ Either way the slot is emitted **empty** and the build is green — which is right
366
+ while nothing poses that state, and wrong the moment something does. So when the
367
+ setup pose names it, the build is refused, and the refusal names the state and
368
+ what was consulted for it rather than the slot alone (§5.1). ⚠️ Its remedy is
369
+ **not** the `null` the neighbouring message offers: posing `null` builds a rig
370
+ with that part missing, at exit 0 and a green gate. Restore the art, or fix the
371
+ path the manifest states.
372
+
373
+ 🔑 **A refused build prints its `DROP` lines too**, under the refusal on stderr,
374
+ and did not before [#671](https://github.com/firejune/rigc/issues/671): they were
375
+ reported from the compile result and a refusal returns none, so the one output
376
+ naming the file was missing from exactly the run that failed over it.
377
+
338
378
  A **turned** region is not one of them, and used to be (issue #570). A pack made
339
379
  by somebody else routinely rotates a region to save space — `rotate: 90`,
340
380
  `rotate: 180`, `rotate: 270`, or the format's older `rotate: true` — and rigc
@@ -489,6 +529,17 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
489
529
  extremes, how far each vertex moved and whether the winding survived
490
530
  (**§4.11.2**). It takes no `--profile`, it never gates, and it does not write
491
531
  anything — so the figures are readable on a build the gate is refusing.
532
+
533
+ ⭐ **It reads what `ingest` writes, given the pack** — `--atlas-in <pack.atlas>`,
534
+ with `build`'s meaning (§0.2). That matters more here than it looks: printing the
535
+ `DEFORM` block means **posing** the rig, a pose resolves *every* attachment
536
+ against the atlas whether or not anything deforms it, and a spec written by
537
+ `ingest --art none` states sizes and names no image. So the size-only pair
538
+ `build --atlas-in` gates green is readable here through the same flag, and
539
+ without it the pair is refused by name at exit 2 rather than posed
540
+ ([#697](https://github.com/firejune/rigc/issues/697), §5.1). ⚠️ `--profile`,
541
+ `--pack`, `--page-size`, `--padding` and `--copy-images` are `build`'s and are
542
+ not here: four of them decide what is *written*, and this command writes nothing.
492
543
  - **`diff`** compares two skeletons and reports **a ratio per measure** in six
493
544
  sections (bones, slots, attachments, constraints, animations, events). It
494
545
  deliberately does not combine them into a score: a rig with the right skeleton
@@ -935,6 +986,12 @@ A skin can also say which bones and constraints it **switches on**, and that nee
935
986
  one more level, so a skin entry has a second spelling — see §3.4.1. The short one
936
987
  above is unchanged and is what almost every rig wants.
937
988
 
989
+ 🔸 **`default` is a name, not a requirement.** A rig may put every attachment in
990
+ named skins and declare no `default` at all, which is what an editor export of a
991
+ multi-skin character gives back; rigc still emits a `default` skin, empty, because
992
+ it always does. An animation keying such a slot resolves its attachment names
993
+ against every skin there is — §4.4 states that rule and §5.1 the one refusal left.
994
+
938
995
  🔸 **A skin may fill no slot with anything that needs art, and that build is
939
996
  green.** The atlas is built out of what the skins reference, so a rig whose skins
940
997
  name no `image` — an empty `default`, or one carrying only a `boundingbox`, a
@@ -1153,6 +1210,110 @@ placed with its centre on the bone the slot names — which is also exactly wher
1153
1210
  plain region attachment with no `x`/`y` would have drawn it. Measured on a ring, a
1154
1211
  ribbon and a moved bone by `CT05` in the selftest (issue #1).
1155
1212
 
1213
+ #### `ring` — an aperture that opens inside a pinned seam
1214
+
1215
+ **When you need one:** a part with a hole in it that a bone opens and closes — an
1216
+ eye, a mouth, an iris. The silhouette stays exactly where the art put it and only
1217
+ the inside moves, which is the one thing a `contour` cannot do and authored
1218
+ `weights` can only do a vertex at a time.
1219
+
1220
+ Three rings and a hub, and every one of them is geometry rigc builds: the part
1221
+ window edge and the seam contour are pinned to the slot bone at weight 1, and the
1222
+ inner ring is shared with the control bone by a smoothstep falloff — flat at both
1223
+ ends, so the deformation dies into the pinned rim instead of creasing against it
1224
+ ([`src/mesh.ts`](../src/mesh.ts)).
1225
+
1226
+ ```json
1227
+ "generator": {
1228
+ "kind": "ring",
1229
+ "hull": [[24, 8], [40, 8], [56, 24], [56, 40], [40, 56], [24, 56], [8, 40], [8, 24]],
1230
+ "center": [32, 32],
1231
+ "inner": 0.45,
1232
+ "size": [64, 64],
1233
+ "controls": ["iris_aperture"]
1234
+ }
1235
+ ```
1236
+
1237
+ | Field | Meaning |
1238
+ | --- | --- |
1239
+ | `hull` | **required.** The seam contour, in part-local pixels, y down — the closed outline the aperture sits inside. At least 6 points, every one inside the window, and star-shaped about `center` |
1240
+ | `center` | **required.** The aperture centre, part-local pixels, y down |
1241
+ | `inner` | **required.** Where the moving ring sits between the centre (`0`) and the hull (`1`), strictly inside that interval. No default: a ring with no number here is refused, not centred |
1242
+ | `size` | **required.** The part window, `[w, h]` in pixels, which the UVs and the emitted `width`/`height` are taken from |
1243
+ | `bias` | optional; absent means authority is radial only. `{ "axis_deg": <screen degrees, y down>, "ramp": [d0, d1] }` — a line through `center` at that angle, with control authority 0 on its negative side and 1 on its positive side, smoothstepped across the signed distances `d0 < d1`. It is what lets a mouth open downward with the upper lip left pinned |
1244
+ | `controls` | **required.** The control bones, by name, at least one. Each must be a bone the rig declares. **More than one splits the ring by angle**, and the angle of each is measured from where the rig put that bone relative to `center` — so the split is a consequence of the skeleton and never a number you write here |
1245
+
1246
+ ⚠️ **`size` is stated here, not measured.** A `contour` and a `grid` take the
1247
+ window off the attachment's own `image`; a `ring` and a `ribbon` are built from
1248
+ the numbers in this block and rigc compares neither to the PNG. The emitted
1249
+ `width`/`height` are this `size`, so a `size` that is not the art's own scales the
1250
+ drawing — measured: a 240x240 part declared `"size": [64, 64]` builds green under
1251
+ `--profile spine-html`. That is R1 rather than a gap: the compiler emits the
1252
+ number the spec states and does not re-measure a plate to overrule it.
1253
+
1254
+ ⭐ **Several grips split the ring by where the rig put them, on this route as on
1255
+ the manifest one.** Each control bone's angle about `center` is measured from its
1256
+ own rest position — the window is centred on the slot bone (the ⭐ above), so a
1257
+ bone sitting below the aperture owns the arc below it — and authority is
1258
+ smoothstepped between the two bones either side of a vertex, so no grip creases
1259
+ against the next. Measured on a two-control ring whose grips sit 12px above and
1260
+ below the centre: 25 vertices, both grips bound, and of the 8 shared vertices off
1261
+ the centre line all 8 take more weight from the grip on their own side. Splitting
1262
+ **moves** authority rather than adding it: every vertex gives its controls the
1263
+ same total with one grip, two or three.
1264
+
1265
+ ⚠️ **This route used to bind only the first name**
1266
+ ([#684](https://github.com/firejune/rigc/issues/684)). It passed no angles at all,
1267
+ so the split collapsed onto `controls[0]` and the rest of the names reached the
1268
+ `MESH` line, the bone list and nothing else — and the gate was green, because a
1269
+ bone no vertex binds is in no weight, no sum and no index. `A20_MESH_WEIGHTS_COHERENT`
1270
+ now names it, so a ring that declares a grip and does not use it is a failure
1271
+ rather than a quiet stiffness.
1272
+
1273
+ **Stated limits, each a named refusal rather than a mesh that loads wrong:**
1274
+
1275
+ | The input | What you get |
1276
+ | --- | --- |
1277
+ | fewer than 6 hull points | `hull needs at least 6 points, got 4` |
1278
+ | `inner` at either end | `inner must be in (0,1), got 1` — at the hull it is the seam, at the centre it is a point |
1279
+ | a hull point outside the window | `hull point (99,8) is outside the 64x64 part window` |
1280
+ | a hull the centre cannot see all of | `hull is not star-shaped about the aperture centre; the inner ring would fold` — the inner ring is the hull scaled toward `center`, so an edge hidden from it crosses the rim and renders as folded meat |
1281
+ | a `bias` ramp that does not increase | `bias ramp must increase, got [16, 4]` |
1282
+ | a control bone the rig does not declare | `mesh bone "nobody" is not in the rig's bone list` |
1283
+ | two or more `controls`, one of them ON `center` | `control bone "iris_aperture" sits on the aperture centre, so it has no radial direction` — the position a lone control is supposed to occupy is the one position a split cannot use. With a single control it is never asked, so this refusal cannot fire on one |
1284
+ | two `controls` at the same angle about `center` | `two control bones share the angle 90 degrees about the aperture centre` — further out is not elsewhere: the arc between them is empty and one of them would bind nothing |
1285
+
1286
+ #### `ribbon` — a strip of cross rows riding a bone chain
1287
+
1288
+ **When you need one:** a part that hangs and swings off a chain — a tail, a
1289
+ strap, a lock of hair, a drip. The strip spans the part window and each cross row
1290
+ blends linearly between the two chain knots it falls between, so the whole part
1291
+ follows the chain with no weight written by hand.
1292
+
1293
+ The entry row is pinned to the slot bone at weight 1, which is what keeps the
1294
+ strip's origin where it was authored while the rest of it falls; that is the
1295
+ branch `A21_MESH_RIM_PINNED` measures on a ribbon, and
1296
+ `A28_RIBBON_ROWS_SHARE_WEIGHTS` is what holds both vertices of a row to the same
1297
+ blend.
1298
+
1299
+ ```json
1300
+ "generator": { "kind": "ribbon", "size": [24, 180], "rows": 7, "chain": ["strap_a", "strap_b", "strap_c"] }
1301
+ ```
1302
+
1303
+ | Field | Meaning |
1304
+ | --- | --- |
1305
+ | `size` | **required.** The part window, `[w, h]` in pixels; the strip spans it, entry row at the top. Stated rather than measured, exactly as a `ring`'s is |
1306
+ | `rows` | **required.** How many cross rows, entry row first. A whole number, at least 3; the strip is `2 * (rows - 1)` triangles and `2 * rows` vertices, all of them on the outline |
1307
+ | `chain` | **required.** The bone chain the strip rides, **root first**, at least one name. The knots sit evenly along the strip, so the chain's order is the order the part falls in |
1308
+
1309
+ **Stated limits:**
1310
+
1311
+ | The input | What you get |
1312
+ | --- | --- |
1313
+ | fewer than 3 rows | `ribbon needs at least 3 rows, got 2` — two rows is one quad and has nothing to bend |
1314
+ | an empty `chain` | `ribbon needs at least one chain bone, got 0` |
1315
+ | a chain bone the rig does not declare | `mesh bone "nobody" is not in the rig's bone list` |
1316
+
1156
1317
  #### `contour` — the mesh is the art's own silhouette
1157
1318
 
1158
1319
  **When you need one:** the part's outline is the interesting thing and a
@@ -1804,7 +1965,7 @@ rigc emits all five: `ik`
1804
1965
  ([transform constraints](http://esotericsoftware.com/spine-transform-constraints)),
1805
1966
  `path` ([path constraints](http://esotericsoftware.com/spine-path-constraints)),
1806
1967
  `physics` ([physics constraints](http://esotericsoftware.com/spine-physics-constraints))
1807
- and `slider`. Field lists are in [`src/rig.ts`](../src/rig.ts); three traps worth
1968
+ and `slider`. Field lists are in [`src/rig.ts`](../src/rig.ts); the traps worth
1808
1969
  carrying here:
1809
1970
 
1810
1971
  - A transform constraint's `properties` names come from a fixed six — `rotate`,
@@ -1819,7 +1980,25 @@ carrying here:
1819
1980
  `scaleY`, spelled `"none"`, `"uniform"` or `"volume"`. It is an enum resolved by
1820
1981
  `Utils.enumValue`, so only the first letter's case is free and an unrecognised
1821
1982
  name is assigned as `undefined` with no error; rigc checks it, like the three
1822
- path modes below. ⚠️ `src/rig.ts` called the physics one **`scaleYMode`** until
1983
+ path modes below.
1984
+ - A physics constraint carries seven **`*Global`** booleans —
1985
+ `inertiaGlobal`, `strengthGlobal`, `dampingGlobal`, `massGlobal`, `windGlobal`,
1986
+ `gravityGlobal` and `mixGlobal` — and "global" is not a scale or a space. Spine
1987
+ 4.3 lets a physics timeline name **no** constraint (`animations.<a>.physics[""]`,
1988
+ which the parser reads as constraint index `-1`, `SkeletonJson.js:1048-1053`);
1989
+ such a timeline drives every active physics constraint whose matching flag is
1990
+ `true` and leaves the rest alone (`Animation.js:2066-2072`). So the flag is one
1991
+ constraint's **opt-in to being driven in bulk**, per tuning value, and it does
1992
+ nothing on its own. The parser's default for all seven is `false`.
1993
+ ⚠️ **rigc emits them and cannot emit the timeline that reads them.** Every
1994
+ physics track in a motion spec names its constraint and that name is resolved
1995
+ against the rig — the empty name is refused, `animation "A" keys unknown physics
1996
+ constraint "" (the rig declares: …)` — so the flags are for a player, or a
1997
+ later hand-edit, that supplies one. What rigc does with them is **pass them
1998
+ through**, `false` included: measured, a constraint stating none emits none, and
1999
+ one stating `"windGlobal": false` emits `"windGlobal": false` rather than
2000
+ dropping it the way the motion spec's `physics` table drops a default (§4.6).
2001
+ - ⚠️ `src/rig.ts` called the physics `ScaleYMode` key **`scaleYMode`** until
1823
2002
  issue #545 — the runtime's field name rather than the format's key — and nothing
1824
2003
  read it, so a spec that wrote `scaleYMode` set no mode and said nothing. A rig
1825
2004
  that still writes it is now refused by name, with `scaleY` beside it.
@@ -2383,7 +2562,7 @@ that SKIP, because the profile excludes an archetype assertion before its body
2383
2562
  could notice the missing field (§5.2).
2384
2563
 
2385
2564
  🚨 **The exception: `meshSlots` is required by a rig that invokes a mesh
2386
- generator** (`ring`, `ribbon`, `contour` — §3.4), and it is a **compile-time**
2565
+ generator** (`ring`, `ribbon`, `contour`, `grid` — §3.4), and it is a **compile-time**
2387
2566
  refusal rather than an assertion. Undeclared means a budget of zero, so the build
2388
2567
  stops before the gate with `N mesh slot(s) emitted but the rig "X" allows 0`.
2389
2568
  Geometry rigc built is geometry rigc will not ship unmeasured; geometry the author
@@ -2391,6 +2570,20 @@ drew is exempt, because rigc did not draw it. So `A13`'s **SKIP** means *this ri
2391
2570
  is unmeasured*, not *this budget is inert* — those are two code paths with one
2392
2571
  name, and reading the SKIP as the whole story is what issue #274 was.
2393
2572
 
2573
+ 🔗 **`detached` is a list of forbidden parentages**, and its shape is
2574
+ `[{ "bone": …, "notUnder": …, "why"?: … }]`. `A25_DETACHED_BONE_PARENTAGE` walks
2575
+ `bone`'s parent chain and fails if `notUnder` is anywhere on it — an **ancestor**
2576
+ rule, not a direct-parent one, because a bone dragged by its grandparent is
2577
+ dragged just the same. A `bone` the rig does not declare is its own failure
2578
+ (`the rig declares "X" detached from "Y" but has no such bone`), so a rename that
2579
+ leaves this block behind is loud rather than quietly vacuous. `why` is optional
2580
+ here — unlike `deformMayFold`'s, which is required, and the difference is what
2581
+ each field does: this one turns a check ON for a pair, so an empty reason costs
2582
+ nothing but a reader's time. What it is for is a bone whose wrong parentage still
2583
+ loads and still animates and merely lies — something released into the world that
2584
+ must not ride the part that released it. [RIGGING.md](RIGGING.md) §10.3 has a
2585
+ worked one.
2586
+
2394
2587
  🚨 **`deformMayFold` is the one field here that turns a check OFF**, so it is the
2395
2588
  one field whose own shape is refused rather than skipped. It is
2396
2589
  `[{ "slot": …, "why": … }]`, it exempts that slot from
@@ -2715,6 +2908,36 @@ different curves. The same applies to `scale`/`scalex`/`scaley` and
2715
2908
  An `attachment` key carries no easing — attachment timelines are inherently
2716
2909
  stepped.
2717
2910
 
2911
+ 🔑 **An attachment key resolves against every skin, not against `default` alone.**
2912
+ A slot's `attachment` timeline carries a name and **no skin** — the format has no
2913
+ field for one — so the names a key may use are the union of every skin's
2914
+ placeholders for that slot, the default skin's included.
2915
+ `Skeleton.getAttachment` resolves the keyed name at run time through the skin the
2916
+ skeleton is **wearing** and, failing that, through `defaultSkin` (spine-core
2917
+ 4.3.13 `Skeleton.js:335-346`), so which skin's art a key lands on is the
2918
+ consumer's, decided by dressing the skeleton rather than by the animation.
2919
+
2920
+ - **A name some skins fill and others do not is accepted, and that is the
2921
+ format's own semantics** rather than a hole in the check: under a skin that
2922
+ lacks it the slot shows nothing, which is exactly what a `null` key says and a
2923
+ thing a rig may well mean. What is refused is a name **no** skin holds, and the
2924
+ refusal says which skins were searched and what the slot does have (§5.1).
2925
+ - A rig with **no `default` skin at all** — every attachment in named skins,
2926
+ which is the shape an editor export of a multi-skin character gives back — is
2927
+ therefore a rig whose attachment keys work. Until
2928
+ [#695](https://github.com/firejune/rigc/issues/695) it was not: keys were
2929
+ checked against the default skin alone, so a named-skin name was refused as
2930
+ unknown, and a rig with no default skin had **every** attachment key refused,
2931
+ including ones whose art is in the first named skin. The setup pose resolved
2932
+ across skins the whole time (§4.2), so the two halves of one slot disagreed —
2933
+ `slots[].attachment: "plain"` was accepted and a key naming `plain` on that
2934
+ same slot was not.
2935
+ - ⚠️ **A `deform` track is the other way round and names its skin outright**
2936
+ (§4.11.5), because the format keys a deform timeline on a `skin/slot/attachment`
2937
+ triple and a deform run is geometry for one attachment object. An attachment
2938
+ timeline has no such field, which is why this one is a union and that one is a
2939
+ lookup.
2940
+
2718
2941
  ### 4.5 `keys` — times, values, curves
2719
2942
 
2720
2943
  - `t` is in seconds and **must strictly increase** after `lag`/`stagger` are added.
@@ -4076,6 +4299,19 @@ at setup, key it somewhere.
4076
4299
 
4077
4300
  Failures arrive in two layers, and they read differently.
4078
4301
 
4302
+ 📥 **A third surface is `ingest`'s, and it is not a failure at all.** What a skeleton
4303
+ holds and the spec format cannot say is **recorded** rather than refused — one coded
4304
+ line per finding, and a non-zero exit only when one of them is a blocker (§0.3's gutter
4305
+ table is the three kinds); both specs are written either way. So there is no file to
4306
+ change for one of those, there is a decision to make. (`ingest` does refuse one thing,
4307
+ and it is not about the file's contents: an *option* that contradicts the file, such as
4308
+ `--stage` beside a box the skeleton declares.)
4309
+ Every code it can print, with its gutter, whether it changes the exit code, what it
4310
+ means and what to do, is the finding-code table in `docs/INGEST.md` §2.0. (Spelled
4311
+ rather than linked, like the references at the top of this page: that page cites the
4312
+ corpus inventory, and a ladder run reads this guide without following its references
4313
+ out of it.)
4314
+
4079
4315
  ### 5.1 Compile errors — before the gate
4080
4316
 
4081
4317
  A `CompileError` names the object and the field, and nothing is written.
@@ -4112,6 +4348,40 @@ a real key in the wrong case leads the list), and the shape's whole key set is
4112
4348
  printed after. What it will *not* do is guess — a key four edits from anything gets
4113
4349
  no suggestion, only the set.
4114
4350
 
4351
+ ⭐ **The scan runs before the required keys are asked for, in both files**, and
4352
+ that is what decides which message a *misspelled* required key gets. Misspelling
4353
+ one is the commonest way to lose it, so it is refused as the typo it is rather than
4354
+ as the thing it lost:
4355
+
4356
+ ```
4357
+ rigc compile error: rig.json: this rig spec has a key this compiler does not read:
4358
+ "slot" (did you mean "slots"?). Nothing reads such a key, so it would be dropped
4359
+ from the emitted skeleton in silence — fix the spelling or remove it. Known here:
4360
+ bones, constraints, events, images, invariants, name, note, skeleton, skins, slots,
4361
+ spec.
4362
+ ```
4363
+
4364
+ A key that is **genuinely** absent has no near miss to name, so the scan finds
4365
+ nothing and the required-key message below it is what arrives — `a rig spec needs a
4366
+ "slots" array (it may be empty; its ORDER is the draw order)` here, and
4367
+ `` `animations` is absent; … `` in a motion spec. Read the two apart by that:
4368
+ **"does not read"** means the key *is* in your file, spelt some other way, and
4369
+ **"needs a …"** / **"is absent"** means it is not in the file at all. Where both
4370
+ are true at once — a typo beside a key that really is missing — the typo is named
4371
+ first, because it is the one you can fix without re-reading the file. Until
4372
+ [#672](https://github.com/firejune/rigc/issues/672) the rig spec's required-key
4373
+ checks ran *above* its scan, so `"slot"` for `"slots"` printed the sentence asking
4374
+ for an array the file already had, one character off, and never named the stray
4375
+ key; the motion spec has always had this order.
4376
+
4377
+ ⚠️ One check stays above the scan in both parsers: the `spec` version tag. The
4378
+ version is what decides *which* key set applies, so a file declaring a version this
4379
+ compiler does not know is refused as that — `unknown rig spec version "rigc-rig/2",
4380
+ expected "rigc-rig/1"` — rather than key by key against a set it never claimed. The
4381
+ price is paid by one spelling: a misspelt `spec` key itself reads as `unknown rig
4382
+ spec version undefined, expected "rigc-rig/1"`, which is true and one line short of
4383
+ naming the typo.
4384
+
4115
4385
  ⚠️ There is **no forward-compatibility escape**, and no `note` field except where
4116
4386
  one is listed: a rig spec's root, a motion spec's root, an animation, and a
4117
4387
  `physics` tuning entry. Prose anywhere else has to go in a document, because a key
@@ -4163,11 +4433,15 @@ or the key's position in its own track. These are the frequent ones, verbatim:
4163
4433
 
4164
4434
  | Message | What to change |
4165
4435
  | --- | --- |
4436
+ | `a rig spec needs a "name" — a motion spec names it to pick this rig` | §3 — name the rig, and make the motion spec's `archetype` equal it |
4437
+ | `a rig spec needs a non-empty "bones" array` | §3.2 — a rig with no bones has nothing to hang a slot on; every one has at least a root |
4438
+ | `a rig spec needs a "slots" array (it may be empty; its ORDER is the draw order)` | §3.3 — write `[]` for a rig that draws nothing. ⚠️ These three arrive only when the key is really absent: **misspelt**, it is the unknown-key refusal above, naming what you wrote |
4166
4439
  | `bone "X" names parent "Y", which is not declared before it` | move `Y` earlier in `bones` |
4167
4440
  | `two bones are called "X"` | bone names are the join key; rename one |
4168
4441
  | `slot "X" names bone "Y", which this rig does not declare` | add the bone, or fix the slot's `bone` |
4169
4442
  | `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 |
4170
- | `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` |
4443
+ | `the setup pose shows attachment "A" on slot "X", which no skin and no manifest part fills` | §3.3 — the slot is emitted empty and nothing was ever going to fill it, so `A` resolves to nothing. Give the slot an attachment (a skin entry or a manifest part), or state the setup pose as `null` |
4444
+ | `the setup pose shows attachment "A" on slot "X", and the slot is emitted empty because the one manifest state that fills it has no art: "s" (no PNG at p)` | §0.2 — a **different** fault with a similar shape: a manifest part does fill this slot and its art was not where the manifest pointed, so every state of it was dropped. **Restore the file or fix the path** — posing `null` compiles, but it ships the rig with that part missing and the gate green. The path printed is the one rigc tried, which is what tells a deleted file from a misspelt `states:` entry; `all N manifest states that fill it have no art` is the same message where the part has more than one. Under `--atlas-in` the same sentence reads `no region "R" in <atlas>` and asks you to add the region to the pack — no file was opened ([#671](https://github.com/firejune/rigc/issues/671)) |
4171
4445
  | `a region needs width and height — give them, or give an "image" and rigc will measure the PNG` | add `image`, or both sizes |
4172
4446
  | `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 |
4173
4447
  | `"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 |
@@ -4177,13 +4451,18 @@ or the key's position in its own track. These are the frequent ones, verbatim:
4177
4451
  | `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 |
4178
4452
  | `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 |
4179
4453
  | `the triangles do not tile the outline: …` / `the triangles' outline is not one closed loop: …` | §3.4 — a doubled triangle, an unused vertex, a pinch or a hole in `triangles` |
4454
+ | `vertex N binds bone "X", which the rig does not declare as a bone` | §3.4 — an authored mesh's `weights` bind by NAME, like everything else in a rig spec. Fix the spelling, or declare the bone. The message names the skin, the slot, the placeholder and the vertex, because an index would name none of them |
4180
4455
  | `image "X.png" is not on disk at …` | fix the name, or point `--images` at the right directory |
4456
+ | `parts/iris_open.png is 96x64 but slot "iris" declares 96x60` | R5 — a manifest `states:` entry whose art is not the window the part declares. Re-export the PNG, or fix the part's `size`; a quad sized against art of another size is the silence `A06` exists for, and the window is what the quad is built from |
4457
+ | `plates/00_stage.png is 256x256 but the manifest window for "stage" is 250x256` | R5 — the same check on the part's unconditional `image`, against the window the crop gives it |
4458
+ | `region "00_stage" of <pack>.atlas (declared 256x256 by its offsets) is 256x256 but the manifest window for "stage" is 250x256` | R5 — the row above under `--atlas-in`, and the prefix is the whole point: it says which of the two rigc **measured**, because the remedy differs. A bare path is a loose PNG it opened and you re-export; a `region … of <pack>` was read out of the pack, and you repack or aim the part at another region. This is the size row of §0.2's four, with the message it actually prints |
4181
4459
  | `duplicate region name "X"` | two PNGs share a basename; one part, one page, one name |
4182
4460
  | `"b/X.png" and the art already atlased as region "X" are two different files — … Rename one of the PNGs.` | R5 — the region name is the basename, so only one of the two can hold it. Rename a file (not a placeholder: the placeholder is free to repeat) |
4183
- | `the image "X.png" was never added to the atlas, so there is no region "X" …` | nothing in the spec — every image an attachment names is measured, so this says rigc skipped one. Report it ([#555](https://github.com/firejune/rigc/issues/555)) |
4461
+ | `the image "X.png" was never added to the atlas, so there is no region "X" …` | nothing in the spec — every image an attachment names is measured and atlased, one per file whichever skin names it, so this says rigc skipped one. Report it on [the tracker](https://github.com/firejune/rigc/issues). The `#555` the message itself cites is the change that made one-per-file an invariant, not a place to file against |
4184
4462
  | `motion spec names archetype "A" but the rig spec at … is called "B"` | make `archetype` equal the rig's `name` |
4185
4463
  | `animation "A" declares duration Ns but its last key is at Ms` | R7 — fix whichever of the two you meant |
4186
4464
  | `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` |
4465
+ | `animation "A" slot "X" attachment: attachment "N" is not in slot "X" under any skin (searched: default, alt) — the slot has: plain, trim` | §4.4 — the keyed name is in **no** skin, and the two clauses say where the compiler looked and what it would have taken. Fix the spelling, or give some skin a placeholder called `N`. A name only a NAMED skin fills is not this error and never was one to fix — it compiles, and the slot shows nothing under the skins that lack it. Before [#695](https://github.com/firejune/rigc/issues/695) the message read `attachment "N" is not in slot "X"` and was raised against the **default skin alone**, so it fired on correct rigs: any key into named-skin art, and every key in a rig with no default skin. `the slot has no attachments at all` is the same message where nothing fills the slot |
4187
4466
  | `animation "A" keys unknown bone "X"` | the track's `bone` is not in the rig |
4188
4467
  | `animation "A" bone "X" translatex: key value must be an array of 1 number(s)` | the value shape must match the property (§4.4) |
4189
4468
  | `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)) |
@@ -4254,6 +4533,35 @@ or the key's position in its own track. These are the frequent ones, verbatim:
4254
4533
  | `slot "patch": placeholder "patch" is filled by the "default" skin AND by skins "zulu", "mike", and the Spine editor has no way to hold that … Move the default skin's entry for this slot into a named skin — call it "base"` | **R12** — do what it says: move that entry out of `default` into a named skin. The editor has no representation for a placeholder the default skin shares with a named one, in either spelling, and §3.4.2 has both measurements. Renaming the placeholder does not help; the shape is what is refused |
4255
4534
  | `N attachment name collision(s): a placeholder that more than one skin fills is emitted with the name "<skin>/<placeholder>" … slot "patch": skin "base" placeholder "zulu/patch" and skin "zulu" placeholder "patch" would both be named "zulu/patch"` | **R12** — rename the placeholder or the skin. rigc composes an attachment name for every placeholder more than one skin fills (§3.4.2), and this fires when a composed name is one another entry in the same slot already answers to — including a plain name in the default skin, which composed nothing. Both sites are named; either rename ends it |
4256
4535
 
4536
+ ⚠️ **One refusal in this section is not a `CompileError`, and it is `explain`'s.**
4537
+ `explain` prints the `DEFORM` block by **posing** the rig, and a pose resolves every
4538
+ attachment against the atlas — so it needs the art `build` needs, reached the same
4539
+ two ways (§0.2). A pair whose art it cannot resolve is refused **before a line of
4540
+ the report**, at **exit 2**, in rigc's own sentence:
4541
+
4542
+ ```
4543
+ rigc explain: skin "default" slot "lamp" placeholder "shade": attachment "shade"
4544
+ wants region "shade", which this build's atlas does not have (it declares no region
4545
+ at all). `explain` poses the rig to measure its deform keys and a pose resolves
4546
+ every attachment against the atlas, so there is nothing to pose it against. Art
4547
+ reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>`
4548
+ resolves the parts against a pack somebody already made, and an "image" per
4549
+ attachment resolves them as loose PNGs under `--images <dir>` — a spec that states
4550
+ a size and names no image is what `ingest --art none` writes, and `--atlas-in` is
4551
+ what reads it. 1 of 1 attachment lookup(s) here resolve to no region.
4552
+ ```
4553
+
4554
+ Both halves of it are measured rather than fixed text: the count is this pair's, and
4555
+ where the atlas has a **near miss** the sentence prints that too, in `A08`'s own
4556
+ clause and `A08`'s own words. When `--atlas-in` **was** given and the region is still
4557
+ absent, the second half names the pack instead and asks you to fix the spec's region
4558
+ name or point the flag at the pack that has it. Until
4559
+ [#697](https://github.com/firejune/rigc/issues/697) there was no rigc sentence at
4560
+ all: the report printed in full and the run then died inside `AtlasAttachmentLoader`
4561
+ with `Region not found in atlas: shade (attachment: shade)` and a spine-core stack
4562
+ trace, at exit 1 — the runtime's message about rigc's internals standing in for
4563
+ rigc's message about your two files.
4564
+
4257
4565
  ### 5.2 Assertions — the gate
4258
4566
 
4259
4567
  The report prints one line per assertion:
@@ -4334,7 +4642,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4334
4642
  | `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)) |
4335
4643
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
4336
4644
  | `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)) |
4337
- | `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)) |
4645
+ | `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, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not ([#684](https://github.com/firejune/rigc/issues/684)). Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4338
4646
  | `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 |
4339
4647
  | `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)) |
4340
4648
  | `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 |
@@ -5657,6 +5965,11 @@ line whenever the MAE is flat and something still looks wrong: a flat MAE says t
5657
5965
  framing and the art agree, and it says nothing at all about whether your shot holds
5658
5966
  and blinks where the reference does.
5659
5967
 
5968
+ ⭐ **And read it when the MAE is loud, because it is what says *which kind* of loud.**
5969
+ It is the only column that separates a wrong curve from a key moved in time — the two
5970
+ builds under *The floor every figure here is read against* are that pair, and the
5971
+ drift column reports 31.1 px and 32.1 px for them.
5972
+
5660
5973
  ⚠️ **Each set is compared against ITSELF, so on a shot committed at two rates a hold
5661
5974
  can exist in one set and not the other.** The coarse set samples every other frame of
5662
5975
  the fine one, so a pair the coarse set holds across is a constraint between samples
@@ -5721,6 +6034,13 @@ There are two matchers and the `how` column says which one answered:
5721
6034
  confidence: how much better the winning position was than the best rival inside
5722
6035
  the search window. This is what gives a shot like a chain of touching links any
5723
6036
  drift at all — under connected components alone, every frame of it is ambiguous.
6037
+ ⭐ **Only the pixels of it your own composite lets show are correlated**: a
6038
+ template pixel you draw something over cannot match the reference wherever the
6039
+ slot really is, so it adds the same residual at every offset — and, because
6040
+ sliding the template moves those samples onto other pixels, their gradient
6041
+ decides the winner wherever the visible basin is shallow. That is not a
6042
+ hypothetical: it is what made four of the seven examples in this repository
6043
+ report 0.8–2.2 px against frames rendered from themselves (issue #698).
5724
6044
 
5725
6045
  ⚠️ **Both matchers are capped, and a blank is a real answer.** A part can be
5726
6046
  displaced by about its own size and still be that part in the picture; past that,
@@ -5730,11 +6050,157 @@ the 47 px course as its drift. The bar rises with the distance being claimed: a
5730
6050
  peak sitting where you already drew the slot only has to confirm it, a peak
5731
6051
  claiming the part moved most of a radius has to be distinctive to be believed.
5732
6052
 
6053
+ ⚠️ **And a slot you cover completely has no drift to report at all.** If every
6054
+ pixel a slot draws is painted over by something later in your own draw order,
6055
+ none of its ink reaches the picture, so there is nothing to correlate — `check`
6056
+ says so by name and counts it out, rather than correlating hidden pixels against
6057
+ whatever is on top of them:
6058
+
6059
+ ```
6060
+ its drift is not measurable — every one of the 2116 px this 48x54 px slot draws is
6061
+ covered by something the candidate draws over it, so none of its own ink reaches the
6062
+ picture to be correlated against
6063
+ ```
6064
+
6065
+ That is a fact about the shot and not an error: a part behind another part is
6066
+ still where the rig put it, and the answer to *where did it land* is that this
6067
+ run cannot say. Read it beside the `slots` column, which counts it as
6068
+ unattributed.
6069
+
5733
6070
  The `slots` column is how many of the slots you drew got an answer at all, and the
5734
6071
  summary line carries the same denominator. `N reference component(s) no slot
5735
6072
  reaches` means the reference frame contains something none of your slots overlaps:
5736
6073
  a part you have not authored, or one you have put somewhere else entirely.
5737
6074
 
6075
+ 🚩 **The floor every figure here is read against, and how to measure it.** `check`
6076
+ grades nothing — `rigc check --help` says so, and the exit code is 0 on any run that
6077
+ could be made — so a figure means something only beside one you took yourself. The
6078
+ cheapest one to take is the **identity run**: a build compared against frames
6079
+ rendered from that same build. It is what this instrument says when the true answer
6080
+ is *nothing moved*.
6081
+
6082
+ ```bash
6083
+ bun cli.ts build --rig gallery/squash/rig.json --motion gallery/squash/motion.json --out <build>
6084
+ bun cli.ts render --candidate <build> --animation bounce --fps 12 --out <frames>
6085
+ bun cli.ts check --candidate <build> --frames <frames>
6086
+ ```
6087
+
6088
+ ```
6089
+ ── bounce — candidate animation "bounce", 12 fps ──
6090
+ frames 11 on disk, candidate samples 11, 11 compared
6091
+ MAE mean 0.00 worst 0.00 (exact: none of the 11 compared frame(s) differs from the reference) (0..255 over the union alpha; over the whole frame, mean 0.00)
6092
+ ⤷ over the REFERENCE's own drawn pixels, mean 0.00 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
6093
+ slot drift worst 0.5 px "plate" at f0004
6094
+ ⤷ bounded by 0.71 px — the correlation put this slot where the candidate drew it, so the whole figure is the sub-pixel step, clamped to 0.5 px on each axis
6095
+ per-frame all 10 adjacent pair(s) change by as much as the reference's own frames do
6096
+ ```
6097
+
6098
+ ⚠️ **The MAE floor is zero and the slot-drift floor is not**, and the second half of
6099
+ that is the instrument's own arithmetic rather than anything about your rig. Both
6100
+ sides are the same pixels, so every frame's MAE is exactly 0 — which is why the line
6101
+ says `(exact)` instead of naming a frame. The drift is a **correlation**: it finds
6102
+ the whole-pixel offset that matches best and then fits a parabola through three
6103
+ whole-pixel residuals for the fraction, clamped to half a pixel on each axis.
6104
+
6105
+ 🔑 **So the bound has two halves, and the run states both of them for you.** The
6106
+ `⤷ bounded by` line is not a constant off this page: it is `hypot(|dx| + 0.5,
6107
+ |dy| + 0.5)` for that match's own whole-pixel winner `(dx, dy)`. A winner at the
6108
+ offset you drew — which is the answer on every frame of a correct rig — bounds the
6109
+ whole figure at `hypot(0.5, 0.5) = 0.71 px`, and the line says the figure is the
6110
+ sub-pixel step and nothing else. A winner one pixel out bounds it at 1.58 px and
6111
+ says the correlation moved the part. ⇒ **Read a drift against the line under it,
6112
+ never against a number from a page about another rig.** A component match prints
6113
+ the other sentence — two centroids have no such bound, only the search radius.
6114
+
6115
+ 🚨 **The clamp bounds the figure only while the whole-pixel winner is the
6116
+ identity, and for a while nothing checked that second half.** Issue #698: four of
6117
+ the seven examples in this repository read **0.81, 1.11, 2.13 and 2.21 px** against
6118
+ frames rendered from themselves, because the template carried the pixels the
6119
+ candidate draws *over itself*. Those match nothing wherever the slot really is, so
6120
+ they add a residual at every offset — and sliding the template moves them onto
6121
+ other pixels, so their gradient walks the winner off the origin wherever the
6122
+ visible basin is shallow. The exhaustive whole-pixel field for `flex`'s backdrop
6123
+ had its minimum at `(2, 0)` scoring 7.75 against the identity offset's 8.76.
6124
+ Correlating only what shows put all seven back on `(0, 0)`, and `C25`–`C30` of
6125
+ the repository's own selftest gate that over every example it ships — so the next
6126
+ one is gated by arriving.
6127
+
6128
+ 🔸 The same page said `3.7 px` before issue #678, for an unrelated reason worth
6129
+ keeping: the coarse sweep started at `−radius` and stepped by its stride, so the
6130
+ identity offset was on the lattice only when the stride divided the radius, and
6131
+ the `±1` refinement around a winner two pixels out could not reach back to it.
6132
+
6133
+ **A second example, and the one the repair was measured on.** `gallery/flex` draws
6134
+ a banner and a leaf over a full-stage backdrop, so almost every slot of it reaches
6135
+ the template matcher:
6136
+
6137
+ ```bash
6138
+ bun cli.ts build --rig gallery/flex/rig.json --motion gallery/flex/motion.json --out <build>
6139
+ bun cli.ts render --candidate <build> --animation wave --fps 12 --out <frames>
6140
+ bun cli.ts check --candidate <build> --frames <frames>
6141
+ ```
6142
+
6143
+ ```
6144
+ ── wave — candidate animation "wave", 12 fps ──
6145
+ frames 30 on disk, candidate samples 30, 30 compared
6146
+ MAE mean 0.00 worst 0.00 (exact: none of the 30 compared frame(s) differs from the reference) (0..255 over the union alpha; over the whole frame, mean 0.00)
6147
+ ⤷ over the REFERENCE's own drawn pixels, mean 0.00 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
6148
+ slot drift worst 0.2 px "plate" at f0009
6149
+ ⤷ bounded by 0.71 px — the correlation put this slot where the candidate drew it, so the whole figure is the sub-pixel step, clamped to 0.5 px on each axis
6150
+ per-frame all 29 adjacent pair(s) change by as much as the reference's own frames do
6151
+ ```
6152
+
6153
+ That figure was **2.21 px** on the same command before #698, on the same slot.
6154
+
6155
+ ⭐ **And the same frames plus two deliberately wrong builds are what say which column
6156
+ answers which question.** Each differs from the build above in exactly one way —
6157
+ every easing reversed `(x1,y1,x2,y2) → (1−x2,1−y2,1−x1,1−y1)`, and the ball's bottom
6158
+ key moved from 0.4 s to 0.62 s — and each is checked against the same identity
6159
+ frames. Both gate green and both exit 0.
6160
+
6161
+ **No run reproduces this:** the candidate is `gallery/squash` with the three curves in its `easings` block reflected through the diagonal, which is an edit to a spec this repository does not carry a second copy of
6162
+
6163
+ ```
6164
+ ── bounce — candidate animation "bounce", 12 fps ──
6165
+ frames 11 on disk, candidate samples 11, 11 compared
6166
+ MAE mean 6.69 worst 11.71 at f0007 (0..255 over the union alpha; over the whole frame, mean 5.74)
6167
+ ⤷ over the REFERENCE's own drawn pixels, mean 6.69 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
6168
+ slot drift worst 31.1 px "ball" at f0006
6169
+ per-frame 1 of 10 adjacent pair(s) change by a different amount than the reference does; worst f0010, yours moved 4152 px where the reference moved 912
6170
+ ```
6171
+
6172
+ **No run reproduces this:** the candidate is `gallery/squash` with the ball's `translatey` key at 0.4 s moved to 0.62 s, which is an edit to a spec this repository does not carry a second copy of
6173
+
6174
+ ```
6175
+ ── bounce — candidate animation "bounce", 12 fps ──
6176
+ frames 11 on disk, candidate samples 11, 11 compared
6177
+ MAE mean 2.15 worst 6.38 at f0007 (0..255 over the union alpha; over the whole frame, mean 1.84)
6178
+ ⤷ over the REFERENCE's own drawn pixels, mean 2.15 — the union figure compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.
6179
+ slot drift worst 32.1 px "ball" at f0007
6180
+ per-frame all 10 adjacent pair(s) change by as much as the reference's own frames do
6181
+ ```
6182
+
6183
+ ⇒ **`per-frame` is the column that separates a wrong curve from a moved key, and it
6184
+ is the only one of the three that does.** The `MAE` says both are wrong and says it
6185
+ threefold louder for the curve (6.69 against 2.15). `slot drift` cannot tell them
6186
+ apart at all — 31.1 px against 32.1 px, the same part, a frame either side — because
6187
+ both put the ball somewhere it is not at the moment being compared. `per-frame` fires
6188
+ on the reversed easings alone, and it is measuring the one thing the two defects do
6189
+ not share: how far each side moved **since its own previous frame**. A key moved in
6190
+ time changes *when* the poses arrive and not how fast the shot travels between two
6191
+ samples, so the column is correctly silent on it. ⇒ Read the pair together: a loud
6192
+ MAE with `per-frame` silent is a pose in the wrong place or at the wrong moment; a
6193
+ loud MAE with `per-frame` firing is a **speed**, which is a curve.
6194
+
6195
+ 🔸 Neither block above reprints its own `⤷ bounded by` line, for the same reason
6196
+ both carry the declaration: the spec edits are not in the tree, so those two lines
6197
+ would be hand-written rather than taken. What the gate measures on them is stated
6198
+ instead — the worst match of each sits at whole-pixel `(0, −31)` and `(0, −32)`,
6199
+ which bounds them at **31.50 px** and **32.50 px**. That is the contrast worth
6200
+ carrying away: a correct rig's drift is bounded by the clamp because its winner is
6201
+ the identity, and these two are bounded by nothing of the kind because a part
6202
+ really moved.
6203
+
5738
6204
  **The `chains` block is the same two measures on the unit you actually repair.**
5739
6205
 
5740
6206
  ```