spine-rigc 0.33.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/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` |
@@ -4086,7 +4086,11 @@ exactly what a mix that was 0 at setup needs said.
4086
4086
  above 1 is a real editor idiom rather than a mistake.
4087
4087
  - ⚠️ A mix is only read by the runtime if the constraint declares the matching
4088
4088
  `properties` mapping (§3.5). Keying `mixScaleY` on a constraint that maps
4089
- rotation only is dead data — legal, loaded, and it moves nothing.
4089
+ rotation only is dead data — legal, loaded, and it moves nothing. ⚠️ So is every
4090
+ mix a key **omits**, and that one looks like a rescue: the parser reads an
4091
+ omitted mix as 1, so a key of `mixRotate: 0` alone on a rotate-only constraint
4092
+ carries five mixes of 1 that nothing reads. `A48` judges only the mixes of the
4093
+ properties the constraint drives (§4.12).
4090
4094
  - The refusals are §4.9's, with `transform` in place of `ik`.
4091
4095
 
4092
4096
  ### 4.11 `deform` — moving an attachment's vertices
@@ -4964,6 +4968,35 @@ constraint declaring `mixGlobal`, since only the physics family has one. The ref
4964
4968
  says both halves — `path constraint "P" has mixRotate 0, mixX 0 and mixY 0 at setup
4965
4969
  and none of the 2 animations keys its mix above 0; …` — and names both repairs.
4966
4970
 
4971
+ ⚠️ **The same question of an `ik` and a `transform` constraint is `A47` and `A48`**
4972
+ ([#765](https://github.com/firejune/rigc/issues/765)); until then no assertion asked
4973
+ it, and a rig resting either kind muted with nothing keying it gated green with 0
4974
+ failures. [measured] on generated fixtures, an ik at `mix` 0 and a transform at
4975
+ every mix 0, each with nothing keying it and each keyed to 0 only, pose every bone
4976
+ exactly where the same rig with no constraint does. They read the timelines through
4977
+ the same helper as `A23`/`A36`/`A37`, so a lifted Bezier between two keys of 0 is a
4978
+ rescue and a 0-only timeline is not, with two differences that are the runtime's:
4979
+
4980
+ - **Live is `!== 0`, not `> 0`.** `IkConstraint.update` returns on `mix === 0`, and
4981
+ a transform applies a property only when its own mix `!== 0`, so a negative mix
4982
+ runs the constraint inverted. [measured] five transform constraints across four of
4983
+ the editor's example exports (`6-arcs-pro`, `8-follow-through-pro-ball`,
4984
+ `sack-pro` twice, `spineboy-pro`) rest at `mixX` = `mixY` = −1 with nothing keying
4985
+ them, each moves its bones against the same constraint at every mix 0, and a
4986
+ `> 0` reading refuses all five. `A36`/`A37` still read `> 0`.
4987
+ - **A transform is judged on the mixes of the properties it drives.**
4988
+ `TransformConstraint.update` returns early only when all six mixes are 0, but a
4989
+ property is applied by its own mix, and a key omitting a mix reads it as 1
4990
+ (§4.10). [measured] a rotate-only transform keyed to `mixRotate: 0` alone — five
4991
+ mixes of 1 on the loaded timeline — and one keyed to `mixX: 1` both pose exactly
4992
+ where no constraint does, and both are refused. A transform whose `properties`
4993
+ name no `to` at all is refused with its own sentence: no mix it carries is read.
4994
+
4995
+ `ik constraint "reach" has mix 0 at setup and none of the 1 animation keys its mix
4996
+ above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it
4997
+ above 0, or key its mix above 0 in an animation`. A rig resting at 0 and keyed up by
4998
+ the animation that needs it — spineboy's aim — is refused by neither.
4999
+
4967
5000
  ### 4.13 `sequence` — which frame of a numbered series shows
4968
5001
 
4969
5002
  The other attachment timeline, beside `deform` and for the same reason: its key is
@@ -5435,10 +5468,12 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
5435
5468
  | `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
5469
  | `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
5470
  | `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 |
5471
+ | `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
5472
  | `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 |
5473
+ | `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 |
5474
+ | `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 |
5475
+ | `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 |
5476
+ | `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
5477
 
5443
5478
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
5444
5479
  clauses are gated by profile.
@@ -739,6 +739,8 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
739
739
  | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | validity | a linked mesh, in either spelling, that also states `uvs`, `triangles`, `vertices`, `hull` or `edges` — keys the `source` branch returns before reading, so the file says one mesh and every runtime draws its source's |
740
740
  | `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | validity | an `rgb` or `alpha` timeline beside another colour timeline of the slot that poses the same channel — the later in the file overwrites the other at every time — or a key of one the pose does not reproduce |
741
741
  | `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | validity | a numbered series that loads as something other than what the file states — a `sequence` block with no `count` (0 regions) or a `setup` past the end (clamped), or a `sequence` key whose `mode` is outside the seven (read as `hold`), whose `index` is fractional or past the end, whose advancing mode runs at an effective delay of 0, or that steps an attachment with no block — and then the pose: every key sampled mid-frame, the region shown held to the frame the file's statement gives ([#729](https://github.com/firejune/rigc/issues/729)) |
742
+ | `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | an ik constraint at `mix` 0 at setup that no animation keys away from 0 — `update()` returns on it, and the rig parses and moves nothing ([#765](https://github.com/firejune/rigc/issues/765)) |
743
+ | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | a transform constraint whose mixes for the properties it drives are all 0 at setup and on every value a key poses, or one that drives no property ([#765](https://github.com/firejune/rigc/issues/765)) |
742
744
  | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
743
745
  | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
744
746
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/validate.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  type ConstraintTimeline,
26
26
  type CurveTimeline,
27
27
  DeformTimeline,
28
+ IkConstraintData,
29
+ IkConstraintTimeline,
28
30
  Inherit,
29
31
  isBoneTimeline,
30
32
  isConstraintTimeline,
@@ -48,6 +50,14 @@ import {
48
50
  TextureAtlas,
49
51
  type TextureAtlasRegion,
50
52
  type Timeline,
53
+ ToRotate,
54
+ ToScaleX,
55
+ ToScaleY,
56
+ ToShearY,
57
+ ToX,
58
+ ToY,
59
+ TransformConstraintData,
60
+ TransformConstraintTimeline,
51
61
  } from '@esotericsoftware/spine-core';
52
62
  // ⚠️ `src/` reaches outside itself for exactly two modules and this is one of
53
63
  // them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
@@ -208,6 +218,8 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
208
218
  A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
209
219
  A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
210
220
  A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
221
+ A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
222
+ A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
211
223
  };
212
224
 
213
225
  /**
@@ -677,6 +689,31 @@ function isObj(v: unknown): v is Json {
677
689
  return typeof v === 'object' && v !== null && !Array.isArray(v);
678
690
  }
679
691
 
692
+ /**
693
+ * The time to pose a key at so that the runtime is AT it: the later of the
694
+ * file's number and that number as spine-core stores it (issue #771).
695
+ *
696
+ * 🚨 Every timeline keeps its key times in a `Float32Array`
697
+ * (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
698
+ * stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
699
+ * the double `0.2`. Stepped to the file's own number, a timeline is then just
700
+ * BEFORE its key: before a first key it writes the setup value (`time <
701
+ * frames[0]`), and past a stepped key it still holds the one before
702
+ * (`frames[i] > time`). That is a pose one float step from the key, not the
703
+ * key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
704
+ * 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
705
+ *
706
+ * ⚠️ The later of the two rather than `Math.fround` alone: where the float
707
+ * rounds DOWN, the file's number is already past the stored key, and a runtime
708
+ * built without typed arrays stores the double itself — in both, the file's
709
+ * number is the one at or after the key. What a key time rounds to is the
710
+ * runtime's storage and not the file's statement, so a rule judging what a KEY
711
+ * states poses at the key; the rounding itself is nothing an author can repair.
712
+ */
713
+ function atStoredKey(time: number): number {
714
+ return Math.max(time, Math.fround(time));
715
+ }
716
+
680
717
  /**
681
718
  * The atlas region names one raw skin entry will make the loader look up — or
682
719
  * `null` when the file states a sequence this walk cannot predict.
@@ -3037,10 +3074,17 @@ export function validate(input: ValidateInput): ValidateReport {
3037
3074
  * A timeline naming no constraint is the physics family's global form and
3038
3075
  * `unnamedPhysicsReach` answers who it reaches; every other constraint
3039
3076
  * timeline names its one constraint by index.
3077
+ *
3078
+ * `live` is handed the channel as well as the value, because not every
3079
+ * channel of every constraint timeline is a mix (issue #765): an ik frame is
3080
+ * mix, softness, bend direction, compress and stretch, so a bend direction of
3081
+ * +1 is not a key that switches anything on, and a transform frame carries
3082
+ * six mixes of which only the ones for a property the constraint drives are
3083
+ * ever read.
3040
3084
  */
3041
3085
  const keyedLive = <T extends CurveTimeline & ConstraintTimeline>(
3042
3086
  owns: (timeline: Timeline) => timeline is T,
3043
- live: (timeline: T, value: number) => boolean,
3087
+ live: (timeline: T, value: number, channel: number) => boolean,
3044
3088
  ): Set<object> => {
3045
3089
  const reached = new Set<object>();
3046
3090
  for (const animation of data.animations) {
@@ -3048,7 +3092,7 @@ export function validate(input: ValidateInput): ValidateReport {
3048
3092
  if (!owns(timeline)) continue;
3049
3093
  let keysLive = false;
3050
3094
  for (let channel = 0; channel < timeline.getFrameEntries() - 1 && !keysLive; channel++) {
3051
- keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value));
3095
+ keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value, channel));
3052
3096
  }
3053
3097
  if (!keysLive) continue;
3054
3098
  const reach =
@@ -3470,6 +3514,108 @@ export function validate(input: ValidateInput): ValidateReport {
3470
3514
  stats.sliderConstraints = sliders.length;
3471
3515
  });
3472
3516
 
3517
+ // --- A47 / A48: an ik or a transform constraint muted for good ----------
3518
+ //
3519
+ // The question A23, A36 and A37 ask of their own kinds, asked of the two
3520
+ // kinds editor exports use most (issue #765): a constraint that rests muted
3521
+ // and that no animation switches on parses, sits in the update cache and
3522
+ // moves nothing. [measured] on generated fixtures, 61 steps at 60 fps: an ik
3523
+ // at `mix` 0 that nothing keys, one keyed to 0 only, a transform at every mix
3524
+ // 0 that nothing keys and one keyed to 0 only each pose every bone exactly
3525
+ // where the same rig with no constraint does (max |Δ| 0.000000), and all four
3526
+ // gated green with 0 failures before these two existed.
3527
+ //
3528
+ // 🔑 **Live is the runtime's own test, `!== 0`, and not `mixLive`'s `> 0`.**
3529
+ // `IkConstraint.update` returns on `mix === 0` and a transform's inner loop
3530
+ // applies a property only when `to.mix(pose) !== 0`, so a negative mix runs.
3531
+ // That is not a corner: [measured] five transform constraints across four of
3532
+ // the editor's own example exports rest at mixX = mixY = −1, nothing keys
3533
+ // them, and each moves its bones at setup against the same constraint with
3534
+ // every mix 0. A `> 0` reading refuses all five. (`A36`/`A37` still read
3535
+ // `> 0` — a path or slider resting negative is a question for their own card.)
3536
+ //
3537
+ // 🔑 **A transform mix is read only for a property the constraint drives.**
3538
+ // The early return in `TransformConstraint.update` is over all six mixes, but
3539
+ // it is not what decides whether anything moves: each `to` entry reads its own
3540
+ // mix (`ToRotate.mix` is `mixRotate`, …). At setup the parser only reads a mix
3541
+ // whose property is declared, so the two tests agree there — but a timeline
3542
+ // key that omits a mix is read as 1 (`SkeletonJson.js`, every `getValue(…, 1)`),
3543
+ // so a key of `mixRotate: 0` alone on a rotate-only constraint passes the
3544
+ // six-mix test on five mixes nothing reads. [measured] that key poses every
3545
+ // bone exactly where no constraint does, and so does one keying `mixX` 1 on
3546
+ // the same constraint. So this reads the mixes of the declared `to` kinds,
3547
+ // at setup and on every value a key poses.
3548
+ const ikLive = (value: number): boolean => value !== 0;
3549
+ /** A transform timeline's six channels, in frame order, and the `to` kind each one is the mix of. */
3550
+ const TRANSFORM_MIXES = [
3551
+ ['mixRotate', ToRotate],
3552
+ ['mixX', ToX],
3553
+ ['mixY', ToY],
3554
+ ['mixScaleX', ToScaleX],
3555
+ ['mixScaleY', ToScaleY],
3556
+ ['mixShearY', ToShearY],
3557
+ ] as const;
3558
+ /** Which of the six channels `constraint` reads at all: the ones whose `to` kind it declares. */
3559
+ const transformReads = (constraint: TransformConstraintData): boolean[] =>
3560
+ TRANSFORM_MIXES.map(([, kind]) => constraint.properties.some((from) => from.to.some((to) => to instanceof kind)));
3561
+ const ikSwitchedOn = keyedLive(
3562
+ (timeline): timeline is IkConstraintTimeline => timeline instanceof IkConstraintTimeline,
3563
+ // Channel 0 is `mix`; the other four are softness, bend direction, compress and stretch.
3564
+ (_timeline, value, channel) => channel === 0 && ikLive(value),
3565
+ );
3566
+ const transformSwitchedOn = keyedLive(
3567
+ (timeline): timeline is TransformConstraintTimeline => timeline instanceof TransformConstraintTimeline,
3568
+ (timeline, value, channel) => {
3569
+ const constraint = data.constraints[timeline.constraintIndex];
3570
+ return constraint instanceof TransformConstraintData && transformReads(constraint)[channel] && value !== 0;
3571
+ },
3572
+ );
3573
+
3574
+ check('A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => {
3575
+ const constraints = data.constraints.filter((c) => c instanceof IkConstraintData);
3576
+ if (!constraints.length) return skip('A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', 'the skeleton declares no ik constraint');
3577
+ for (const constraint of constraints) {
3578
+ const mix = constraint.setupPose.mix;
3579
+ if (ikLive(mix) || ikSwitchedOn.has(constraint)) continue;
3580
+ fail(
3581
+ 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT',
3582
+ `ik constraint "${constraint.name}" has mix ${mix} at setup and ${noneKeysItsMixAbove0(data.animations.length)}; ` +
3583
+ `update() returns on mix 0, so ${constraint.bones.map((bone) => `"${bone.name}"`).join(' and ')} never ` +
3584
+ `reach${constraint.bones.length === 1 ? 'es' : ''} for "${constraint.target.name}" — ${REST_OR_KEY_ITS_MIX}`,
3585
+ );
3586
+ }
3587
+ });
3588
+
3589
+ check('A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => {
3590
+ const NAME = 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
3591
+ const constraints = data.constraints.filter((c) => c instanceof TransformConstraintData);
3592
+ if (!constraints.length) return skip(NAME, 'the skeleton declares no transform constraint');
3593
+ for (const constraint of constraints) {
3594
+ const where = `transform constraint "${constraint.name}"`;
3595
+ const reads = transformReads(constraint);
3596
+ const pose = constraint.setupPose;
3597
+ const read = TRANSFORM_MIXES.filter((_, i) => reads[i]).map(([field]) => field);
3598
+ if (read.length === 0) {
3599
+ // No `to` at all: no mix is ever read, so neither remedy below applies.
3600
+ fail(
3601
+ NAME,
3602
+ `${where} drives no property — its \`properties\` name no \`to\` — so no mix it carries is ever read and it ` +
3603
+ 'moves nothing; declare the property it should drive',
3604
+ );
3605
+ continue;
3606
+ }
3607
+ if (read.some((field) => pose[field] !== 0) || transformSwitchedOn.has(constraint)) continue;
3608
+ fail(
3609
+ NAME,
3610
+ `${where} drives ${read.map((field) => field.slice(3).replace(/^./, (c) => c.toLowerCase())).join(', ')} and has ` +
3611
+ `${read.map((field) => `${field} ${pose[field]}`).join(', ')} at setup, and ` +
3612
+ `${noneKeysItsMixAbove0(data.animations.length)}; a mix is read only for a property the constraint drives, and ` +
3613
+ `update() skips each one at 0, so nothing ever moves ${constraint.bones.map((bone) => `"${bone.name}"`).join(', ')} — ` +
3614
+ `rest ${read.length === 1 ? read[0] : `one of ${read.join(', ')}`} above 0, or key its mix above 0 in an animation`,
3615
+ );
3616
+ }
3617
+ });
3618
+
3473
3619
  // --- A40: two sliders on one property, and the later one erases the other -
3474
3620
  //
3475
3621
  // 🚨 The hole A37 leaves. Every clause above is INTRA-slider — it asks
@@ -4300,9 +4446,10 @@ export function validate(input: ValidateInput): ValidateReport {
4300
4446
  skeleton.setupPose();
4301
4447
  skeleton.update(0);
4302
4448
  skeleton.updateWorldTransform(Physics.reset);
4303
- state.update(time);
4449
+ // At the key as the runtime stores it — see `atStoredKey` (#771).
4450
+ state.update(atStoredKey(time));
4304
4451
  state.apply(skeleton);
4305
- skeleton.update(time);
4452
+ skeleton.update(atStoredKey(time));
4306
4453
  skeleton.updateWorldTransform(Physics.update);
4307
4454
  const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
4308
4455
  const light = posed?.color;
@@ -4472,7 +4619,9 @@ export function validate(input: ValidateInput): ValidateReport {
4472
4619
  skeleton.setupPose();
4473
4620
  skeleton.update(0);
4474
4621
  skeleton.updateWorldTransform(Physics.reset);
4475
- state.update(time);
4622
+ // At the key as the runtime stores it, not one float step before
4623
+ // it — see `atStoredKey` (issue #771).
4624
+ state.update(atStoredKey(time));
4476
4625
  state.apply(skeleton);
4477
4626
  const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
4478
4627
  if (!posed) {
@@ -4792,7 +4941,10 @@ export function validate(input: ValidateInput): ValidateReport {
4792
4941
  skeleton.setupPose();
4793
4942
  skeleton.update(0);
4794
4943
  skeleton.updateWorldTransform(Physics.reset);
4795
- state.update(sample.time);
4944
+ // A `hold` sample is AT its key, so it is posed at the key as
4945
+ // the runtime stores it (`atStoredKey`); a mid-frame sample is
4946
+ // half a delay from any key and is posed where it is.
4947
+ state.update(sample.key >= 0 && sample.steps === 0 ? atStoredKey(sample.time) : sample.time);
4796
4948
  state.apply(skeleton);
4797
4949
  const pose = skeleton.slots[slotIndex].appliedPose;
4798
4950
  const shown = pose.attachment;