spine-rigc 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -533,9 +533,9 @@ first three work on any reference you have, and `bench` is a repository workflow
533
533
  and `bun run fetch-examples`. The reasoning behind them is in
534
534
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
535
535
 
536
- `build` and `validate` both default to `--profile spine` — the 32 validity rules, which
536
+ `build` and `validate` both default to `--profile spine` — the 34 validity rules, which
537
537
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
538
- adds all 47: the other 15 are one renderer's policy and one canvas budget's, and they
538
+ adds all 49: the other 15 are one renderer's policy and one canvas budget's, and they
539
539
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
540
540
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
541
541
  yourself: author under `--profile spine-html` and read the extra 15 as findings, and
@@ -683,7 +683,7 @@ letting `A17` blame the editor for the harness's own doing.
683
683
  | 📥 **[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 |
684
684
  | 🤖 **[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 |
685
685
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
686
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 47 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 |
686
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
687
687
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
688
688
  | 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
689
689
  | 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
@@ -740,7 +740,7 @@ quality."* All six, with their verdicts, are in
740
740
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
741
741
 
742
742
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
743
- see, every rung, the run viewer, the 47 assertions and the selftest behind them — is
743
+ see, every rung, the run viewer, the 49 assertions and the selftest behind them — is
744
744
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
745
745
  Live rung status is
746
746
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -86,7 +86,7 @@ import {
86
86
  type AtlasRegion,
87
87
  } from './src/atlas.ts';
88
88
  import { parseJsonWithPosition } from './src/json-position.ts';
89
- import { KEY_TIME_EPSILON } from './src/timelines.ts';
89
+ import { float32Step } from './src/timelines.ts';
90
90
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
91
91
  import {
92
92
  DEFAULT_MAX_RESIDUAL,
@@ -623,8 +623,30 @@ function meshFit(m: CompileResult['meshes'][number]): string {
623
623
  // why, in the place the figures stood, and prints no figure.
624
624
  if (m.fitWithheld !== undefined) return ` fit not measured: ${m.fitWithheld}. ${PAGE_GRID_UNLOCATED}`;
625
625
  if (m.coverage === undefined) return '';
626
- const hole = m.holePixels ? `, enclosing ${m.holePixels}px of hole` : '';
627
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${hole}`;
626
+ // A count of the plate's own cells, so on a `scale:` page it is texels and
627
+ // says so rather than borrowing the overshoot's unit beside it (issue #762).
628
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}${m.pageScale === undefined ? 'px' : ' texel(s)'} of hole` : '';
629
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${meshFitGrid(m)}${hole}`;
630
+ }
631
+
632
+ /**
633
+ * The grid a fit was taken on, when it is not the drawing's own (issue #762).
634
+ *
635
+ * The overshoot is stated in the drawing's pixels on every route — the unit an
636
+ * attachment's size is in — and on a page that declares a `scale:` other than
637
+ * 1 it was measured on the page's texels and divided by that scale. So it
638
+ * carries the coarser grid's step: on `scale: 0.5` a figure moves in steps of
639
+ * 2.00px of the drawing, and it need not equal the figure the page it was
640
+ * packed from reads except where the distance falls on whole texels. Said
641
+ * beside the figure, and nothing at all on a loose part or a page at scale 1,
642
+ * where the line is the one it always was.
643
+ */
644
+ function meshFitGrid(m: CompileResult['meshes'][number]): string {
645
+ if (m.pageScale === undefined) return '';
646
+ return (
647
+ ` (the drawing's pixels, measured on the page's texels at scale: ${m.pageScale} — a texel is ` +
648
+ `${(1 / m.pageScale).toFixed(2)}px of the drawing)`
649
+ );
628
650
  }
629
651
 
630
652
  /**
@@ -739,12 +761,14 @@ function deformKeyName(key: DeformKeyMeasure): string {
739
761
  * The report's is the spec's own `t`; the survey's came back through
740
762
  * `Float32Array`, because that is what `spine-core` reads a timeline's frames
741
763
  * into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
742
- * is the compiler's own key-time grid plus one float32 ulp at this magnitude,
743
- * which is narrower than any key spacing the format can hold and wide enough for
744
- * both roundings.
764
+ * is one float32 step at this magnitude (`float32Step`): the compiler emits a
765
+ * key time as a float's name — the spec's own time when it names one, the float
766
+ * below it when it does not (issue #716) — so the loaded float is within one
767
+ * step of the spec's `t` either way, which is narrower than any key spacing the
768
+ * format can hold.
745
769
  */
746
770
  function sameKeyTime(specTime: number, loaded: number): boolean {
747
- return Math.abs(specTime - loaded) <= KEY_TIME_EPSILON + Math.abs(loaded) * 2 ** -23;
771
+ return Math.abs(specTime - loaded) <= float32Step(loaded);
748
772
  }
749
773
 
750
774
  /**
package/docs/AUTHORING.md CHANGED
@@ -169,7 +169,7 @@ What the flags mean:
169
169
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
170
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
171
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
172
- | `--profile` | `spine` = the 32 validity rules (**the default**) · `spine-html` = all 47, opt-in |
172
+ | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
173
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 |
174
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 |
175
175
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
@@ -324,6 +324,14 @@ texel count beside it so both numbers are visible:
324
324
  measures the PNG. Reach for `--atlas-in` when the pack is what you were handed, or
325
325
  when drawing through the pack's own texels is the point.
326
326
 
327
+ 📐 **What you make beside the art stays at the art's size**
328
+ ([#762](https://github.com/firejune/rigc/issues/762)). A depth sheet or a soft
329
+ mask is read in the drawing's pixels on a `scale:` page as on loose parts — each
330
+ vertex's texel position over the stated scale — and a mesh fit's overshoot is
331
+ printed in them, with the texel it was measured on named beside it. What stays
332
+ in texels is what is taken off them: a `contour`'s trace, whose `margin` and
333
+ `tolerance` are applied on the texels there are (below).
334
+
327
335
  🚨 **A page that declares a size it does not have is a different thing, and it is
328
336
  refused** ([#715](https://github.com/firejune/rigc/issues/715)). The common shape
329
337
  is a pack whose `4096x4096` pages ship as `2048x2048` PNGs with the atlas
@@ -387,11 +395,34 @@ that cannot be withheld, because its outline *is* its geometry: it is refused as
387
395
  compile error carrying `A06`'s whole sentence — ratio and repair — on `explain`
388
396
  and on `build` alike, where on `build` it arrives before the gate would have said
389
397
  it. Carry out the repair and every figure comes back. ⚠️ They come back measured
390
- on the **coarser** texels the page really has, so they need not equal the figures
391
- of the pack the page was halved from: on the same fixture the `scale: 0.5`
392
- restatement reads the authored mesh at 100.00% coverage reaching **8.00px** past
393
- it (the overshoot is counted in the page's own texels), and traces the contour as
394
- 11 vertices where the full-resolution page traced 15.
398
+ on the **coarser** texels the page really has, so a figure that depends on the
399
+ grid need not equal the one the pack the page was halved from reads: on the same
400
+ fixture the `scale: 0.5` restatement traces the contour as 11 vertices where the
401
+ full-resolution page traced 15, because a trace runs on the texels there are and
402
+ its `margin` and `tolerance` are applied on them.
403
+
404
+ 📐 **A fit's overshoot is stated in the drawing's pixels on every page**
405
+ ([#762](https://github.com/firejune/rigc/issues/762)). It is a distance, and on a
406
+ page that declares a `scale:` it is taken on the page's texels — so it used to be
407
+ printed in them, under the same `px`: one mesh over one drawing read 16.00px on
408
+ the declared-size page, **8.00px** on its `scale: 0.5` restatement and **32.00px**
409
+ on a `scale: 2` one. It is now the texel distance over the scale the atlas states,
410
+ which is the unit an attachment's `width` is in and the one you drew in, and the
411
+ line says which grid it was taken on — a texel of a `scale: 0.5` page is 2.00px of
412
+ the drawing, so that is the step the figure moves in:
413
+
414
+ ```bash
415
+ # fan authored 9 vertices / 8 triangles (budget 200) bones=[fan] covers 100.00% of the art, reaching 16.00px
416
+ # past it (the drawing's pixels, measured on the page's texels at scale: 0.5 — a texel is 2.00px of the drawing)
417
+ ```
418
+
419
+ All three pages read the fan at 16.00px, because its rim lands on whole texels of
420
+ each. A figure that does not is exact only to that step — the contour above reads
421
+ 6.00px on the `scale: 0.5` page (3.00 texels) against the full page's 3.16px, a
422
+ different outline measured on a coarser grid, and the line's clause is what says
423
+ so. On a loose part and a page at scale 1 the texels are the drawing, and the line
424
+ is the one it always was. A `contour`'s hole count is a count of those cells and
425
+ says `texel(s)` on such a page.
395
426
 
396
427
  🚨 **A page that is not a PNG is refused by name, before anything is compiled
397
428
  against it** ([#732](https://github.com/firejune/rigc/issues/732)). rigc reads PNG
@@ -707,7 +738,10 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
707
738
  count of vertices on undrawn texels — are replaced by `fit not measured: …` and
708
739
  `… is not measured: …` naming the page and the ratio. A `contour` on such a page
709
740
  is refused, since its outline is read off those texels. Why, the quoted lines,
710
- and the repair: §0.2.
741
+ and the repair: §0.2. On a page that **does** declare a `scale:`, the overshoot
742
+ in that line is the drawing's pixels and the line names the texel it was taken
743
+ on (`… measured on the page's texels at scale: 0.5 — a texel is 2.00px of the
744
+ drawing`, [#762](https://github.com/firejune/rigc/issues/762)).
711
745
  - **`diff`** compares two skeletons and reports **a ratio per measure** in six
712
746
  sections (bones, slots, attachments, constraints, animations, events). It
713
747
  deliberately does not combine them into a score: a rig with the right skeleton
@@ -922,7 +956,7 @@ one frame (1/60 s) is a compile error, and assertion `A09` re-checks it against
922
956
  That frame of slack is for a duration declared *longer* than the motion — an
923
957
  animation may hold its final pose. In the other direction there is no slack to give:
924
958
  **no key may land past the declared duration**, and this is checked per timeline
925
- rather than per animation, within 1e-6 s. Both halves matter, and the second is not
959
+ rather than per animation, within one float32 step of the duration. Both halves matter, and the second is not
926
960
  the first with a smaller number — see §4.5.
927
961
 
928
962
  **R8 — `from` needs a cut manifest.** `from.anchor` / `from.slotWindow` /
@@ -1092,7 +1126,7 @@ number where the box would be:
1092
1126
  | --- | --- | --- |
1093
1127
  | `build` | emits `x`/`y`/`width`/`height` | emits none of them |
1094
1128
  | `A14_NO_FULL_FRAME_MESH` | fails a mesh as big as the stage | **SKIP**, by name |
1095
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the one image that covers the stage | exempts nothing, and says so |
1129
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the base plate a cut manifest names — the part whose window is the crop — and, on a build that names none, the one image that covers the stage | a manifest build exempts the plate it names, exactly as with one ([#770](https://github.com/firejune/rigc/issues/770)). A rig-spec build names no base plate, so an opaque part is refused with *"this skeleton declares no stage size to measure one against, and the build names no base plate"* and the two ways to decide it: a `skeleton` stage the plate covers, or *"build from a cut manifest, whose base plate is the part whose window is the crop"* |
1096
1130
  | `diff` | `stage_present` and `stage_box` | `stage_present` 1/1 when neither side declares one (agreement), `stage_box` 0/0 |
1097
1131
  | `explain` | `stage W x H` | `stage none declared` |
1098
1132
  | `render` | frames the posed extent of every animation | the same frames, plus a line saying the viewport is the posed extent and no stage |
@@ -1869,7 +1903,7 @@ off a greyscale sheet in the part's own pixel grid:
1869
1903
 
1870
1904
  | Field | Meaning |
1871
1905
  | --- | --- |
1872
- | `image` | **required.** The sheet, relative to the rig's `images` directory, and the **same pixel size as this attachment's `image`**. It is not packed into the atlas — it is a measurement rigc reads at compile time, not art anything draws |
1906
+ | `image` | **required.** The sheet, relative to the rig's `images` directory, and the **same pixel size as this attachment's `image`**. It is not packed into the atlas — it is a measurement rigc reads at compile time, not art anything draws. Under `--atlas-in` that is still the drawing's size, on a page that declares a `scale:` too: the sheet is read at each vertex's texel position over the scale the atlas states, so one sheet serves the loose parts and every pack of them ([#762](https://github.com/firejune/rigc/issues/762)) |
1873
1907
  | `near` | **required.** `"white"` or `"black"` — which end of the range is closest to the viewer. Stated rather than defaulted: both conventions are in use, and a sheet read with the wrong one turns the part inside out with every gate still green |
1874
1908
  | `zScale` | **required.** How many world units the map's full range spans, in the attachment's own units — the number `radius` used to carry. 8 bits of level say nothing about scale, so this is authored, never measured |
1875
1909
  | `gamma`, `contrast`, `bias` | the tone curve applied to the nearness, defaults `1` / `1` / `0`. State them when a consumer's own renderer curves the same sheet, so the mesh and that renderer describe one surface |
@@ -1936,7 +1970,7 @@ bun cli.ts build --rig gallery/look/rig.json \
1936
1970
  1st pct yaw +19.32° x1.000 of 80 / -19.32° x1.000 of 80 pitch +22.92° x1.000 of 102 / -26.94° x1.000 of 130
1937
1971
  first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it, which is 0.112 of the range this mesh sampled
1938
1972
  MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l]
1939
- depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511]
1973
+ depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.87451]
1940
1974
  32 of 39 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not
1941
1975
  turn ceiling yaw +17.04° / -45.80° pitch +none / -none
1942
1976
  1st pct yaw +unranked of 12 / -unranked of 36 pitch +none / -none
@@ -2034,6 +2068,8 @@ refuses it instead:
2034
2068
  | a sheet cut to the art's alpha, on a **contour** | `does not cover 12 of the mesh's 12 vertices … A contour mesh puts every vertex ON the silhouette and pushes it out by the margin … Dilate the sheet past the mesh margin, or lower the margin.` |
2035
2069
  | a sheet cut to the art's alpha, on a **grid** | `does not cover 36 of the mesh's 81 vertices … A grid spans the whole part window, corners included … Dilate the sheet to the window, or state "us"/"vs" that keep the lattice inside the art.` — the two topologies run out of sheet for different reasons, and the message says which |
2036
2070
  | a sheet that is not the part's size | `the depth map … is 32x32 and the part is 64x64. A depth map is sampled in the part's own pixel grid` |
2071
+ | a sheet at the page's texel size, on a page that declares a `scale:` | `the depth map "lattice_texels.png" is 48x32 and the part is a 96x64 drawing — 48x32 texels on a page that declares scale: 0.5, which makes a texel 2px of the drawing. … so the sheet is 96x64, the size of the loose art, whatever the page holds.` — the part's own pixel grid is the drawing's on every route, and the message names the ratio and all three sizes, because the drawing's is in neither file |
2072
+ | a sheet cut to the art, on a `scale:` page | the coverage refusal above, with the vertex's position in the page's texels **and** `pixel (x, y) of the drawing-sized sheet` — the pixel of the file you made |
2037
2073
  | a colour sheet | `the depth map … is not greyscale — pixel (0, 0) is rgb(10, 200, 10)` |
2038
2074
  | `zScale` at or below 0 | `it is how many units the map's full range spans, so a positive number. To put the near end at the back, say "near": "black"` |
2039
2075
  | `gamma` or `contrast` at or below 0 | `collapses the range onto the midpoint … so the map would describe a flat part` |
@@ -2101,7 +2137,7 @@ hanging sleeve.
2101
2137
  | Field | Meaning |
2102
2138
  | --- | --- |
2103
2139
  | `bone` | **required.** The bone the region is carried by. It has to already exist — a bone a physics constraint targets is part of the skeleton, not a side effect of a mesh |
2104
- | `mask` | **required.** A greyscale sheet in the part's own pixel grid: the level IS the weight, black still and white fully carried, sampled at each vertex. Alpha is not read — a transparent pixel is black |
2140
+ | `mask` | **required.** A greyscale sheet in the part's own pixel grid: the level IS the weight, black still and white fully carried, sampled at each vertex. Alpha is not read — a transparent pixel is black. The grid is the drawing's, as for a depth map: on a `scale:` page the mask is still the art's size ([#762](https://github.com/firejune/rigc/issues/762)) |
2105
2141
 
2106
2142
  The remainder always stays on the slot bone, so every vertex closes at 1 by
2107
2143
  construction rather than by `A20` catching it later. `build` and `explain` report
@@ -2131,7 +2167,7 @@ bone, must close at 1, and at least one must actually be carried.
2131
2167
  | the slot's own bone | `moves nothing — a soft region needs a bone that can move independently` |
2132
2168
  | a mask that is black everywhere | `carries no vertex of this mesh — every one of its 49 vertices samples black` |
2133
2169
  | a colour mask | `is not greyscale — pixel (0, 0) is rgb(10, 200, 10)` |
2134
- | a mask that is not the part's size | `is 48x32 and the part is 96x64` |
2170
+ | a mask that is not the part's size | `is 48x32 and the part is 96x64` — on a `scale:` page, `and the part is a 96x64 drawing — 48x32 texels on a page that declares scale: 0.5` |
2135
2171
  | a mask that is not on disk | `the soft mask "x.png" is not at …` |
2136
2172
 
2137
2173
  ⭐ **One depth pass buys both, on one part.** A carried mesh has two bones on the
@@ -2987,10 +3023,11 @@ the mapping above and drives the bone to the value it names, rather than playing
2987
3023
  the animation on a track while the slider sits at its neutral. §4.11.4 is what
2988
3024
  that changes and why it matters at `mix: 1`.
2989
3025
 
2990
- 🔸 **`scale` is rounded to six decimals on emit**, like every other number rigc
2991
- writes, so `1/60` ships as `0.016667` — 2e-5 relative. Invisible in the middle of
2992
- the range; it shows at the top of it, where a 60° turn then applies at 1.00002 s
2993
- rather than 1 s. With `loop: false` that is the last frame and harmless, with
3026
+ 🔸 **`scale` is emitted as its float32**, like every other number rigc writes, so
3027
+ `1/60` ships as `0.016666668` — 8e-8 relative (it was `0.016667`, 2e-5, on the
3028
+ six-decimal grid before issue #716). Invisible in the middle of the range; it
3029
+ shows at the top of it, where a 60° turn then applies at 1.00000008 s rather than
3030
+ 1 s. With `loop: false` that is the last frame and harmless, with
2994
3031
  `loop: true` it wraps to the start. When the range comes from a *measured* ceiling
2995
3032
  — the turn ceiling `build` reports for a depth mesh (§3.4, `depth`) is the natural
2996
3033
  one — pick `to`/`scale` so the endpoint lands **inside** the duration rather than
@@ -3561,26 +3598,33 @@ consumer's, decided by dressing the skeleton rather than by the animation.
3561
3598
  Seconds, not frames: nothing requires a key to land on any frame grid, and a
3562
3599
  reference rendered at some rate says nothing about where its keys are. Put keys
3563
3600
  where the motion changes.
3564
- - **Key times are quantised onto a 1e-6 s grid by rounding DOWN, never to
3565
- nearest.** A key time is a position against the sample grid a player will step,
3601
+ - **Key times are emitted as float32s like every other number, and never stored
3602
+ LATER than you wrote them.** Every emitted number is the shortest decimal naming
3603
+ its float32 — the text the editor writes, and the precision the runtime keeps,
3604
+ because `spine-core` reads a timeline's frames into a `Float32Array` (issue #716).
3605
+ A time that already names a float — `0.5`, `0.2`, the editor's `1.4333333` — is
3606
+ written as you wrote it. A time that does not — `2/12` computed in doubles, a key
3607
+ moved by `lag` or `stagger` — steps to the **largest float not above it**, never
3608
+ to nearest. A key time is a position against the sample grid a player will step,
3566
3609
  and the two directions of a half-step error are not the same size. `2/12 s` and
3567
- `5/30 s` are both 0.16666666…; `0.166667` is *larger* than either, so a key
3568
- emitted there is applied at sample **3** of a 12 fps playback and not sample 2 —
3569
- a whole frame late, with nothing raised. On a **stepped** timeline (an attachment
3570
- timeline always is) that is the wrong picture rather than a slightly wrong value:
3571
- the spineboy run's muzzle flare fired a frame late for exactly this until the
3572
- run's own frame check caught it (issue #99). Rounding down cannot do that; the
3573
- worst it can do is put a key a millionth of a second early, on the sample it was
3574
- written for. ⚠️ What this does **not** protect you from is rounding your own
3575
- times before you write them — write `2/12`, not `0.1667`, and let the compiler
3610
+ `5/30 s` are both 0.16666666…, and the nearest float to that, 0.1666666716…, is
3611
+ *larger* than either, so a key stored there is applied at sample **3** of a 12 fps
3612
+ playback and not sample 2 — a whole frame late, with nothing raised. On a
3613
+ **stepped** timeline (an attachment timeline always is) that is the wrong picture
3614
+ rather than a slightly wrong value: the spineboy run's muzzle flare fired a frame
3615
+ late for exactly this, on the six-decimal grid rigc emitted until #716, until the
3616
+ run's own frame check caught it (issue #99). Stepping down cannot do that; the
3617
+ worst it can do is store a key one float early — 1.5e-8 s at 1/6 s — on the sample
3618
+ it was written for. ⚠️ What this does **not** protect you from is rounding your
3619
+ own times before you write them — write `2/12`, not `0.1667`, and let the compiler
3576
3620
  do the quantising.
3577
- - 🚨 **Nor does it protect a stepped key whose time is ALREADY on the 1e-6 grid.**
3578
- Rounding down leaves such a time exactly where you wrote it, and the sampler does
3579
- not arrive there: a player — and `sampleAnimation`, and therefore `check` — reaches
3580
- sample *i* by accumulating `1/fps` *i* times, which for many *i* lands a few ULPs
3581
- **below** `i/fps`. `2/12` is saved by the rule above precisely because it is *not*
3582
- on the grid; `0.25`, `0.5`, `0.75`, `1` and every other multiple of `0.25 s` is, and
3583
- a stepped key there sits above the sample that was meant to see it. On an
3621
+ - 🚨 **Nor does it protect a stepped key whose time ALREADY names a float.**
3622
+ Such a time is written exactly as you wrote it, and the sampler does not arrive
3623
+ there: a player — and `sampleAnimation`, and therefore `check` — reaches sample *i*
3624
+ by accumulating `1/fps` *i* times, which for many *i* lands a few ULPs **below**
3625
+ `i/fps`. `2/12` is saved by the rule above precisely because it is *not* a float;
3626
+ `0.25`, `0.5`, `0.75`, `1` and every other multiple of `0.25 s` is one exactly,
3627
+ and a stepped key there sits above the sample that was meant to see it. On an
3584
3628
  interpolated timeline that costs a few ULPs of value and nothing else. On a
3585
3629
  **stepped** one it is the whole frame — and on the last sample it is the whole
3586
3630
  event, because there is no later sample to catch it. Measured on rung 5's 6.5 s
@@ -3590,16 +3634,22 @@ consumer's, decided by dressing the skeleton rather than by the animation.
3590
3634
  `6.499999999999994` — which read as a frame-change disagreement the pose series had
3591
3635
  already fixed, and cost that run three builds
3592
3636
  ([`2026-08-26-rung5-1`](https://github.com/firejune/rigc/blob/main/bench/runs/2026-08-26-rung5-1/LOOP.md), §8). ⇒ **For a
3593
- stepped timeline, write `T − 1e-6` rather than `T`.** One grid step early cannot
3594
- reach the previous sample — 83,333 µs away at 12 fps — and is always seen by the
3595
- sample it was written for; one ULP late loses the frame. This is the same asymmetry
3596
- the rule above turns on, one grid step further in.
3637
+ stepped timeline, write a time a little below `T` — `T − 1e-6` still works, at
3638
+ any magnitude — rather than `T`.** What makes it work is not the size of the step:
3639
+ any time below `T` is stored on a float below `T`, because a time that names a
3640
+ float is stored at that float and one that does not steps down, and the float
3641
+ below `T` is below the accumulated sample too (one float step is 3.0e-8 s at
3642
+ 0.5 s and 4.8e-7 s at 6.5 s, against the few-ULP shortfall of the sampler). What
3643
+ bounds it from the other side is the frame: `T − 1e-6` cannot reach the previous
3644
+ sample, 83,333 µs away at 12 fps. One ULP late loses the frame. This is the same
3645
+ asymmetry the rule above turns on, one float further in.
3597
3646
  - **No key may land past the animation's `duration`.** Nothing that plays the
3598
3647
  animation for the duration it declares ever reaches such a key, so it is a
3599
3648
  compile error — checked on **every timeline**, not just on the latest key in the
3600
- animation. The tolerance is 1e-6 s, which is one step of the grid rigc rounds key
3601
- times onto, so a key you put exactly *on* a duration that is not a round number
3602
- of microseconds is fine. R7's frame of slack does not apply in this direction and
3649
+ animation, against the float the key is stored at. The tolerance is one float32
3650
+ step at the duration — 4.8e-7 s at 5 s, 3.8e-6 s at 32 s — which is the most a
3651
+ key you put exactly *on* a duration the float cannot hold is stored past it, so
3652
+ such a key is fine. R7's frame of slack does not apply in this direction and
3603
3653
  would not see this: rung 6 rounded its key times to 4 dp somewhere upstream, its
3604
3654
  one-frame reveal landed 0.000034 s past a 68/12 s duration, another track was
3605
3655
  already sitting on the declared duration so the animation's *longest* key time
@@ -3787,7 +3837,7 @@ group members (the per-member values of one track, side by side — issue #295)
3787
3837
  t = 0.20944 rad
3788
3838
  cos t − 1 = -0.021852
3789
3839
  sin t = 0.207912
3790
- shift the parent carries = −carried·sin t = -35.344987
3840
+ shift the parent carries = −carried·sin t = -35.344986
3791
3841
  eye_l 5.513083 <- -62 at depth 150
3792
3842
  eye_r 2.803385 <- 62 at depth 150
3793
3843
  brow_l 3.849789 <- -62 at depth 158
@@ -4086,7 +4136,11 @@ exactly what a mix that was 0 at setup needs said.
4086
4136
  above 1 is a real editor idiom rather than a mistake.
4087
4137
  - ⚠️ A mix is only read by the runtime if the constraint declares the matching
4088
4138
  `properties` mapping (§3.5). Keying `mixScaleY` on a constraint that maps
4089
- rotation only is dead data — legal, loaded, and it moves nothing.
4139
+ rotation only is dead data — legal, loaded, and it moves nothing. ⚠️ So is every
4140
+ mix a key **omits**, and that one looks like a rescue: the parser reads an
4141
+ omitted mix as 1, so a key of `mixRotate: 0` alone on a rotate-only constraint
4142
+ carries five mixes of 1 that nothing reads. `A48` judges only the mixes of the
4143
+ properties the constraint drives (§4.12).
4090
4144
  - The refusals are §4.9's, with `transform` in place of `ik`.
4091
4145
 
4092
4146
  ### 4.11 `deform` — moving an attachment's vertices
@@ -4439,15 +4493,15 @@ bun cli.ts explain --rig gallery/portrait/rig.json \
4439
4493
  t = 0.20944 rad
4440
4494
  cos t − 1 = -0.021852
4441
4495
  sin t = 0.207912
4442
- centre shift = −radius·sin t = -35.344987
4443
- 25 vertices, largest offset 35.344987px at vertex 2
4444
- v 0 (-7.17493, 0) v 1 (-22.413595, 0) v 2 (-35.344987, 0) v 3 (-27.658171, 0)
4496
+ centre shift = −radius·sin t = -35.344986
4497
+ 25 vertices, largest offset 35.344986px at vertex 2
4498
+ v 0 (-7.17493, 0) v 1 (-22.413595, 0) v 2 (-35.344986, 0) v 3 (-27.65817, 0)
4445
4499
  v 4 (-14.255108, 0) v 5 (-14.255108, 0) v 6 (-14.255108, 0) v 7 (-14.255108, 0)
4446
- v 8 (-14.255108, 0) v 9 (-27.658171, 0) v 10 (-35.344987, 0) v 11 (-22.413595, 0)
4500
+ v 8 (-14.255108, 0) v 9 (-27.65817, 0) v 10 (-35.344986, 0) v 11 (-22.413595, 0)
4447
4501
  v 12 (-7.17493, 0) v 13 (-7.17493, 0) v 14 (-7.17493, 0) v 15 (-7.17493, 0)
4448
- v 16 (-22.413595, 0) v 17 (-35.344987, 0) v 18 (-27.658171, 0) v 19 (-22.413595, 0)
4449
- v 20 (-35.344987, 0) v 21 (-27.658171, 0) v 22 (-22.413595, 0) v 23 (-35.344987, 0)
4450
- v 24 (-27.658171, 0)
4502
+ v 16 (-22.413595, 0) v 17 (-35.344986, 0) v 18 (-27.65817, 0) v 19 (-22.413595, 0)
4503
+ v 20 (-35.344986, 0) v 21 (-27.65817, 0) v 22 (-22.413595, 0) v 23 (-35.344986, 0)
4504
+ v 24 (-27.65817, 0)
4451
4505
  ```
4452
4506
 
4453
4507
  The curve reads `stepped` where the spec says `"ease": "swell"`, and that is
@@ -4455,12 +4509,17 @@ The curve reads `stepped` where the spec says `"ease": "swell"`, and that is
4455
4509
  offsets, so the segment between them would draw nothing and is written the way
4456
4510
  the editor writes it.
4457
4511
 
4458
- 📌 **Float behaviour, stated.** The closed forms are evaluated in float64 and
4459
- quantised to six decimals like every other emitted number, so the same spec emits
4460
- the same bytes and `A18_DETERMINISTIC_EMIT` proves it on a second compile. The
4461
- runtime then loads those decimals into a `Float32Array`, which is equally true of
4462
- a hand-written table — the difference the generator makes is that the decimals
4463
- now agree with a stated model instead of with a transcription.
4512
+ 📌 **Float behaviour, stated.** The closed forms are evaluated in float64,
4513
+ quantised onto the model's own 1e-6 grid and emitted as that value's float32 name
4514
+ like every other number, so the same spec emits the same bytes and
4515
+ `A18_DETERMINISTIC_EMIT` proves it on a second compile. The grid is absolute on
4516
+ purpose: a model's identities — a wave sampled on its zero crossings, a whole
4517
+ revolution — are exact zeros float64 misses by ~1e-16, and the refusal of a key
4518
+ that states a deformation and evaluates to nothing is decided on it (issue #350);
4519
+ a float32 alone is relative and has no zero to land on. The runtime then loads
4520
+ the numbers into a `Float32Array`, which is equally true of a hand-written table —
4521
+ the difference the generator makes is that the numbers now agree with a stated
4522
+ model instead of with a transcription.
4464
4523
 
4465
4524
  🔭 **Both adjacent asks have since landed.**
4466
4525
  [#295](https://github.com/firejune/rigc/issues/295) was the same complaint about
@@ -4964,6 +5023,35 @@ constraint declaring `mixGlobal`, since only the physics family has one. The ref
4964
5023
  says both halves — `path constraint "P" has mixRotate 0, mixX 0 and mixY 0 at setup
4965
5024
  and none of the 2 animations keys its mix above 0; …` — and names both repairs.
4966
5025
 
5026
+ ⚠️ **The same question of an `ik` and a `transform` constraint is `A47` and `A48`**
5027
+ ([#765](https://github.com/firejune/rigc/issues/765)); until then no assertion asked
5028
+ it, and a rig resting either kind muted with nothing keying it gated green with 0
5029
+ failures. [measured] on generated fixtures, an ik at `mix` 0 and a transform at
5030
+ every mix 0, each with nothing keying it and each keyed to 0 only, pose every bone
5031
+ exactly where the same rig with no constraint does. They read the timelines through
5032
+ the same helper as `A23`/`A36`/`A37`, so a lifted Bezier between two keys of 0 is a
5033
+ rescue and a 0-only timeline is not, with two differences that are the runtime's:
5034
+
5035
+ - **Live is `!== 0`, not `> 0`.** `IkConstraint.update` returns on `mix === 0`, and
5036
+ a transform applies a property only when its own mix `!== 0`, so a negative mix
5037
+ runs the constraint inverted. [measured] five transform constraints across four of
5038
+ the editor's example exports (`6-arcs-pro`, `8-follow-through-pro-ball`,
5039
+ `sack-pro` twice, `spineboy-pro`) rest at `mixX` = `mixY` = −1 with nothing keying
5040
+ them, each moves its bones against the same constraint at every mix 0, and a
5041
+ `> 0` reading refuses all five. `A36`/`A37` still read `> 0`.
5042
+ - **A transform is judged on the mixes of the properties it drives.**
5043
+ `TransformConstraint.update` returns early only when all six mixes are 0, but a
5044
+ property is applied by its own mix, and a key omitting a mix reads it as 1
5045
+ (§4.10). [measured] a rotate-only transform keyed to `mixRotate: 0` alone — five
5046
+ mixes of 1 on the loaded timeline — and one keyed to `mixX: 1` both pose exactly
5047
+ where no constraint does, and both are refused. A transform whose `properties`
5048
+ name no `to` at all is refused with its own sentence: no mix it carries is read.
5049
+
5050
+ `ik constraint "reach" has mix 0 at setup and none of the 1 animation keys its mix
5051
+ above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it
5052
+ above 0, or key its mix above 0 in an animation`. A rig resting at 0 and keyed up by
5053
+ the animation that needs it — spineboy's aim — is refused by neither.
5054
+
4967
5055
  ### 4.13 `sequence` — which frame of a numbered series shows
4968
5056
 
4969
5057
  The other attachment timeline, beside `deform` and for the same reason: its key is
@@ -5411,7 +5499,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
5411
5499
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
5412
5500
  | `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)) |
5413
5501
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
5414
- | `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)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it** ([#715](https://github.com/firejune/rigc/issues/715)), and #705's clause does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so the scan came back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappeared**, the scan having found a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it** ([#732](https://github.com/firejune/rigc/issues/732)): one row per page naming its parts and pointing at `A06`, which names what the file is — where it used to print `threw: cannot decode PNG …: unexpected end of file`, an inflate error about a file that was never a PNG. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
5502
+ | `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 base plate may be opaque, and which image that is is decided once for both routes ([#770](https://github.com/firejune/rigc/issues/770)): the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). 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)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it** ([#715](https://github.com/firejune/rigc/issues/715)), and #705's clause does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so the scan came back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappeared**, the scan having found a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it** ([#732](https://github.com/firejune/rigc/issues/732)): one row per page naming its parts and pointing at `A06`, which names what the file is — where it used to print `threw: cannot decode PNG …: unexpected end of file`, an inflate error about a file that was never a PNG. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
5415
5503
  | `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)) |
5416
5504
  | `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 |
5417
5505
  | `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)) |
@@ -5435,10 +5523,12 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
5435
5523
  | `A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET` | both | two or more sliders whose animations key the same timeline, where a later one is not `additive` — it writes that property outright at `mix: 1` and every earlier slider on it is dead (§3.5.2). Also fires when the shared timeline **cannot** be applied additively (a slot colour, an attachment swap, a draw order, an ik mix, a path's `spacing`, most physics properties), where `"additive": true` is not the fix and one of the two has to go. ⭐ Which of the two it is, is **posed rather than read off `Timeline.additive`**: the shared timeline is applied twice with `add` set and the detail says what it did ([#655](https://github.com/firejune/rigc/issues/655) — two classes declare that flag falsely about themselves, so a path constraint's `mix` and a slider's `time` were refused although they compose). The detail names the bone or slot and the property, every slider keying it in `constraints` order with its flag, which one wins today, and the class that was posed. Four shapes are deliberately not findings: a slider below `mix: 1` or with its `mix` keyed (the apply is then a lerp from the current pose, not an overwrite), two `skinRequired` sliders no skin activates together, two sliders on different properties, and a shared timeline that writes **nothing a pose holds** — an `events` timeline fires no event under a slider (`firedEvents` is null), so neither slider has anything there for the other to erase. **SKIP** when fewer than two sliders are at full authority; a PASS means two were compared |
5436
5524
  | `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first ([#540](https://github.com/firejune/rigc/issues/540)). The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
5437
5525
  | `A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER` | both | a slider whose animation keys a property of a constraint **at or before it** in `constraints` (§3.5.2) — a slider's `mix` or `time`, an ik or transform mix, a path `position`, `spacing` or `mix`, any physics value. That array is the update order for every kind, and each constraint reads its own applied pose when its turn comes — `Slider.update` takes `mix` as the alpha it applies with and `time` as the time it applies at, `PhysicsConstraint.update` returns on `mix` 0 before reading the rest — so the key lands after the only read of it and `Posed.resetConstrained` discards it before the next frame: what the driven constraint drives is dead at every position of the driving dial, although its pose still holds the number ([#658](https://github.com/firejune/rigc/issues/658), [#665](https://github.com/firejune/rigc/issues/665)). The detail names the slider, the driven constraint with its kind, both array indices, the property, the runtime class whose `update` reads it, and the animation the key sits in. Fix by moving the driver earlier, or by keying that property from a slider that already is. **The two indices equal is the same failure**: a slider cannot key its own `mix` or `time`, and one muted at setup that keys its own `mix` up never applies anything at all — `A37` is silent there, because it asks whether *an* animation keys the mix and not which one. **Two shapes it deliberately leaves out**, both measured: a `physics` `reset` key, which fires on a crossed frame time and so never fires from a slider at all, in either order — the reorder would repair nothing; and a physics timeline naming no constraint, which is every physics constraint declaring that property global and IS refused for the ones already run. Disjoint from `A40` by construction: `A40` asks who writes a shared property last and excludes every slider whose `mix` is keyed, this asks whether anything reads what was written. **SKIP** when the skeleton declares no slider, and when no slider's animation keys a constraint property — that SKIP names any `reset` keys it found — a pass means a driver and a driven were compared |
5438
- | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` / `rgb2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` or `rgb2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. An `rgb2` key's light colour is compared over its three channels only: the light alpha is not its to state, and that it is left where it was is measured in the selftest (`S85`). **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` or `rgb2` — there is then no two-colour tint to read back |
5526
+ | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` / `rgb2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` or `rgb2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before (a correct file was refused that way until [#771](https://github.com/firejune/rigc/issues/771)), and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. An `rgb2` key's light colour is compared over its three channels only: the light alpha is not its to state, and that it is left where it was is measured in the selftest (`S85`). **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` or `rgb2` — there is then no two-colour tint to read back |
5439
5527
  | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read ([#710](https://github.com/firejune/rigc/issues/710)) |
5440
- | `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | both | an `rgb` or `alpha` timeline (§4.4) the runtime does not hold as the file states it, in one of two shapes that both parse in silence. **A channel keyed twice**: another colour timeline of the same slot in the same animation poses a channel this one poses — `rgba` beside `alpha` is the shape a converter leaves when it writes a separable `rgb` back as `rgba` next to the `alpha` it kept. Every colour timeline poses its channels at every time, the setup value before its first key included, so the one the file states later overwrites the other everywhere; the detail names both timelines, the channel, and which one survives. **A key not posed as written**: the animation is stepped to each key's own time and the posed r g b (for `rgb`, against the hex, to half a quantisation step) or alpha (for `alpha`, against `value`, whose absence the parser reads as 0) is compared — a colour that is not six hex digits loads as NaN, and a key whose time another key repeats is read by nothing. ⚠️ An `rgb` alone written as an `rgba` holding the setup alpha is **not** caught and cannot be from the file: it is a correct `rgba`, and the difference shows only under another track that moves the alpha. The loaded timeline class and the channels a separable timeline leaves alone are measured in the selftest (`S83`–`S85`) rather than here, because against the linked parser neither can come out wrong. The channel table is `SLOT_COLOR_CHANNELS` in `src/timelines.ts`, shared with the compiler's refusal and held to the runtime's own property ids (`S89`). **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
5441
- | `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13 ([#729](https://github.com/firejune/rigc/issues/729)). **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
5528
+ | `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | both | an `rgb` or `alpha` timeline (§4.4) the runtime does not hold as the file states it, in one of two shapes that both parse in silence. **A channel keyed twice**: another colour timeline of the same slot in the same animation poses a channel this one poses — `rgba` beside `alpha` is the shape a converter leaves when it writes a separable `rgb` back as `rgba` next to the `alpha` it kept. Every colour timeline poses its channels at every time, the setup value before its first key included, so the one the file states later overwrites the other everywhere; the detail names both timelines, the channel, and which one survives. **A key not posed as written**: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before (a correct file was refused that way until [#771](https://github.com/firejune/rigc/issues/771)), and the posed r g b (for `rgb`, against the hex, to half a quantisation step) or alpha (for `alpha`, against `value`, whose absence the parser reads as 0) is compared — a colour that is not six hex digits loads as NaN, and a key whose time another key repeats is read by nothing. ⚠️ An `rgb` alone written as an `rgba` holding the setup alpha is **not** caught and cannot be from the file: it is a correct `rgba`, and the difference shows only under another track that moves the alpha. The loaded timeline class and the channels a separable timeline leaves alone are measured in the selftest (`S83`–`S85`) rather than here, because against the linked parser neither can come out wrong. The channel table is `SLOT_COLOR_CHANNELS` in `src/timelines.ts`, shared with the compiler's refusal and held to the runtime's own property ids (`S89`). **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
5529
+ | `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13 ([#729](https://github.com/firejune/rigc/issues/729)). **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode, and a `hold` key to its own time as the runtime stores it, a 32-bit float ([#771](https://github.com/firejune/rigc/issues/771)) — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
5530
+ | `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | an ik constraint resting at `mix` 0 that no animation keys **away from 0** (§4.9, §4.12). `IkConstraint.update` returns on `mix === 0`, so it sits in the update cache and moves nothing. The keys are read the way `A23`/`A36`/`A37` read theirs — every value the loaded timeline poses on its `mix` channel, Bezier samples included — so a timeline keying 0 only is no rescue: [measured] it poses every bone exactly where the same rig with no constraint does, and before [#765](https://github.com/firejune/rigc/issues/765) it passed. Live is the runtime's `!== 0`, so a negative mix is not refused. `ik constraint "C" has mix 0 at setup and none of the 1 animation keys its mix above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it above 0, or key its mix above 0 in an animation`. **SKIP** when the skeleton declares no ik constraint |
5531
+ | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | a transform constraint none of whose mixes **for a property it drives** is away from 0 at setup or on any value an animation poses (§4.10, §4.12), or one whose `properties` name no `to` at all. A property is applied only when its own mix `!== 0`, and a key that omits a mix reads it as 1, so the six-mix early return of `TransformConstraint.update` would take a key of `mixRotate: 0` alone as a rescue — [measured] that key, and one keying `mixX` 1 on a rotate-only constraint, pose every bone exactly where no constraint does ([#765](https://github.com/firejune/rigc/issues/765)). A negative mix runs, and five transforms in the editor's example exports rest at −1. `transform constraint "C" drives rotate and has mixRotate 0 at setup, and none of the 1 animation keys its mix above 0; a mix is read only for a property the constraint drives, and update() skips each one at 0, so nothing ever moves "follower" — rest mixRotate above 0, or key its mix above 0 in an animation`. **SKIP** when the skeleton declares no transform constraint |
5442
5532
 
5443
5533
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
5444
5534
  clauses are gated by profile.
@@ -7809,11 +7899,14 @@ under 4.3.26 (`[152.7006, 305.4012, 458.1019, 610.8025]`) and on an open one und
7809
7899
  ⚠️ **That last reading settles the model and cannot settle the spelling.** A
7810
7900
  4-sample chord sum agrees with the runtime's forward difference to about **nine
7811
7901
  significant digits** — *below* what float32 can hold, which is why both spellings
7812
- reproduce both exports exactly, and *above* the six decimals rigc emits, which is
7813
- why the file can tell them apart. On both rigs above they round apart on the
7814
- **last** curve, where the running total has accumulated most: `610.802519` against
7815
- `610.802520`, `1127.735817` against `1127.735818`. So the editor is the evidence
7816
- for *what* is computed, and only `PathConstraint` itself is evidence for *how*.
7902
+ reproduce both exports exactly, and since issue #716 below what rigc's own file
7903
+ holds too, because rigc now writes each number as its float32. Under the six fixed
7904
+ decimals it wrote until then the file could tell them apart: on both rigs above
7905
+ they rounded apart on the **last** curve, where the running total has accumulated
7906
+ most — `610.802519` against `610.802520`, `1127.735817` against `1127.735818`. So
7907
+ the editor is the evidence for *what* is computed, and only `PathConstraint`
7908
+ itself is evidence for *how* — `PS67`/`PS68` compare the transcription with it at
7909
+ double precision.
7817
7910
 
7818
7911
  ⭐ **rigc emits the forward difference itself** since
7819
7912
  [#560](https://github.com/firejune/rigc/issues/560) — `pathCurveLengths` in
@@ -7954,6 +8047,7 @@ Per part:
7954
8047
  | --- | --- |
7955
8048
  | `part`, `path`, `width`, `height` | the PNG, by the name every message uses |
7956
8049
  | `placement` | the best placement found — `null` **only** for `empty-part` and `larger-than-canvas`, where nothing was searched |
8050
+ | `walls` | the walls of the search window `placement` stands **on**, whatever the verdict: `[]` when it settled inside, otherwise one `{ axis, edge, window }` per axis — `axis` is `scale` or `rotation`, `edge` is `floor` or `ceiling`, `window` is the flag's own `min,max`. A value on a wall is where the search was held, not where it came to rest — see §11.4 |
7957
8051
  | `alternates` | other optima worth reporting, best first. Non-empty means the answer was not unique |
7958
8052
  | `ambiguous` | at least one alternate is inside the ambiguity margin. **Choose with something this instrument cannot see** — anatomy, the other frame, or `rigc vote` |
7959
8053
  | `rotationFree` | the part is self-similar under rotation, so `rotationDeg` is a placeholder and the value is yours |
@@ -8008,9 +8102,22 @@ holds nothing because it already contains every angle.
8008
8102
  That is the case where the window is the first thing to move rather than the
8009
8103
  frame or the threshold — eleven parts of one frame came back refused at
8010
8104
  `scale=0.500` against art rendered at `0.311` per part pixel, and the message
8011
- said only that the residual was above `--max-residual`. It is printed on a
8012
- **refusal and nowhere else**: an accepted placement sitting on a wall is a window
8013
- chosen to bracket the answer, which is the flag working.
8105
+ said only that the residual was above `--max-residual`. The sentence — with its
8106
+ *"may lie below"* — is printed on a refusal only.
8107
+ - 🔒 **An accepted placement that stopped on a wall says which wall too**, beside
8108
+ the value it holds on the console line and in `walls`:
8109
+ `PLACE head.png x= 45.5 y= 37.5 rot= 0.0° scale=2.000 (the ceiling of --scale 0.5,2) residual=0.0394 unexplained= 10%`
8110
+ is a part drawn at twice the scale of a frame rendered at 1.15 px/unit, whose
8111
+ truth is **2.30** — outside the window — and whose residual at the ceiling still
8112
+ cleared `--max-residual`. *On* a wall is not *near* one: the refinement is clamped
8113
+ to the window, so a value the window held **is** the bound, while a correct
8114
+ placement a window brackets closely settles strictly inside it and carries no
8115
+ mark. A window with no interior (`min === max`) marks nothing — being at its only
8116
+ value says nothing about the answer. ⚠️ The mark names the wall and not the side
8117
+ the truth is on: over the rendered example corpus, a floor held both truths below
8118
+ the window and parts shrunk into their own region whose truth was inside it. An
8119
+ empty `walls` is not evidence either way — a window that excludes the truth can
8120
+ still settle inside on another optimum, which is the caveat two bullets up.
8014
8121
  - ⚠️ **A frame whose border has no dominant colour reports `background.unknown`.**
8015
8122
  Every pixel then counts as material, the silhouette signal is gone, and the
8016
8123
  residual is colour agreement alone. The report says so rather than being quietly
@@ -8258,7 +8365,7 @@ simply unused; a name the directory lacks is refused `no-part-image` by name.
8258
8365
  | --- | --- |
8259
8366
  | `--anchor <pose.json>` | use this `rigc pose` report instead of running one. Refused together with `--scale` / `--rotation`, which size the internal pass that then does not happen |
8260
8367
  | `--atlas` | **refused by name.** Every other `--candidate` command takes it, so trying it here is reasonable — but the part art comes from `--images` and the skeleton is all this needs of the candidate, so a flag that silently did nothing would be worse than one that says why |
8261
- | `--hinge <min,max>` | the window each child's local rotation is searched over, in Spine degrees about its setup value. Default `-180,180` — **a full turn, on purpose**: one degree of freedom is cheap enough to sweep exhaustively, and §11.4's warning about a window that does not contain the truth applies here too |
8368
+ | `--hinge <min,max>` | the window each child's local rotation is searched over, in Spine degrees about its setup value. Default `-180,180` — **a full turn, on purpose**: one degree of freedom is cheap enough to sweep exhaustively, and §11.4's warning about a window that does not contain the truth applies here too. The hinge step `3°` is a **ceiling** on the step, not the step: a window it does not divide is divided into whole steps no coarser than it, and `search` states the step that division produced, so `--hinge -20,20` prints `hinge -20°–20° step 2.857° (15 rungs)` and each chain part's note says it was searched `in 2.857° steps`. The full turn divides it exactly, so the default walks the same rungs it always did |
8262
8369
  | `--stretch <ratio>` | also search a uniform bone scale, this ratio either way. Without it, stretch is searched **only where your own animations key a `scale` timeline on that bone** — a rig that never scales a bone is a rig saying that bone does not stretch |
8263
8370
  | `--min-visible <0..1>` | below this visible share a placement is refused `occluded` instead of reported flat (default `0.25`). A reporting threshold, not a pass bar; the placement is still in the JSON. ⚠️ **It is not inert, though**: a bone whose frozen share is under this floor gets one *unmasked* look before its visible set is fixed, so the flag also changes **where parts land** and not only which rows are refused. Two runs at different `--min-visible` are two fits, and their shares are not one column |
8264
8371
  | `--max-residual <0..1>` | as §11, over the visible pixels (default `0.25`) |