spine-rigc 2.14.1 → 2.15.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
@@ -688,7 +688,13 @@ bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Co
688
688
  Of the editor's `--version` it records one line, the `Spine <x.y.z> …` line that
689
689
  carries the version, and nothing else that command prints — the rest of it
690
690
  names the licence holder — or `editor version: not found in --version output`
691
- where no such line is there. It prints the import and export exit codes, the
691
+ where no such line is there. That call carries the same `-u` as the import and
692
+ the export, so a trip pinned with `--editor-version <v>` names the editor it
693
+ pinned and says so on the line (`(read from --version under -u <v>, …)`); an
694
+ unpinned trip names the launcher's default. Once the export exists the version
695
+ is held against the export's own `skeleton.spine`, and a disagreement is a FAIL
696
+ naming both values — the line would otherwise name an editor the trip did not
697
+ run on. It prints the import and export exit codes, the
692
698
  validator's verdict on the export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
693
699
  animation **for each skin the build and the export both declare** — one
694
700
  render-and-check block per skin, with a per-skin roll-up under them, because a
package/docs/AUTHORING.md CHANGED
@@ -2617,8 +2617,8 @@ mesh rather than a rim prefix, and `A28_RIBBON_ROWS_SHARE_WEIGHTS` **SKIPs** wit
2617
2617
  | --- | --- |
2618
2618
  | every pixel opaque | `every pixel of the 96x64 part reaches alpha 1, so its silhouette IS the part window and a contour mesh of it is a region attachment with extra vertices` |
2619
2619
  | two or more islands | `the art is 2 separate islands and one outline can only enclose the largest (529 of 989 px, 53.49%)` — one outline encloses one region; give each island its own slot |
2620
- | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
2621
- | a **diagonal pinch** — two parts of the art meeting at one pixel corner | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice` |
2620
+ | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. A hole is a transparent pixel that no path of transparent pixels — stepping through edges *or corners* — reaches from the part's border: the island is 4-connected, so a transparent pixel that meets the outside only at a corner is outside, not a hole. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
2621
+ | a **diagonal pinch** — two parts of the art meeting at one pixel corner, with the outside on the other diagonal | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice. Move the alpha threshold so the pinch opens (raise it) or closes (lower it), or author the geometry as weights` — the corner is scanned for on the **filled** silhouette, the one the outline is traced around. A corner where two holes meet, or a hole and the art, is inside that silhouette and the outline never reaches it, so it is **not** a pinch: a 4x4 block with (1,1) and (2,2) clear traces as the square, enclosing 2px of hole. A 3x3 ring with its centre and (2,2) clear is refused at (2,2), because the centre meets the outside there |
2622
2622
  | a neck narrower than `margin` | `the outline crosses itself: edge 0 meets edge 3 after a margin of 3px was pushed out of a silhouette narrower than that` |
2623
2623
  | more outline than `maxVertices` | `the silhouette simplified to 15 vertices at tolerance 1.5, past the 4 this mesh allows` — refused, never silently decimated |
2624
2624
  | a `margin` too small for the `tolerance` | `the mesh covers 91.81% of the art (2936 of 3198 px), under the 99.5% a contour mesh guarantees` |
@@ -3394,6 +3394,13 @@ carrying here:
3394
3394
  raw JSON the parser throws.
3395
3395
  - Each transform mix is read **only if the matching `to` property was declared**, so
3396
3396
  a `mixRotate` without a `rotate` entry is dead data.
3397
+ - An **ik** constraint's `bones` is **one bone, or two where the second's parent is the
3398
+ first**. The runtime's update applies one bone or two and nothing else, so an ik over
3399
+ three loads clean and moves nothing; and the two-bone solve places the second bone
3400
+ through the first's matrix from the second's own local offset, so a bone standing
3401
+ between the pair is left out of the triangle it solves and the tip misses the target
3402
+ in every frame. rigc refuses both by name (§5.1), and `A47` refuses them in a file a
3403
+ rig spec did not write (§5.2). A chain longer than two is several constraints.
3397
3404
  - A physics constraint's five components all default to 0, so one that names none of
3398
3405
  them parses cleanly and does nothing at all. rigc refuses it up front, and `A23`
3399
3406
  catches it from the other side.
@@ -6132,6 +6139,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
6132
6139
  | `<file>: <where> is Infinity; a number in this file is finite at float32 precision, at most ±3.4028234663852886e+38, because the skeleton is written as float32 — …` (also `is -Infinity`, `is 1e+308`, and `is NaN` from a library caller) | §3 — state the number you meant, inside float32's range. One walk over the whole file, run right after the key scan and before any other rig-spec check, so a range rule never prints `NaN`. `<where>` is every number in the rig spec — a bone (`bone "hip" x`), a slot, a skin's attachment down to its arrays (`skin "default" slot "s" attachment "a" weights[3][1].x`), a constraint (`constraint "aim" mixRotate`), an event, `skeleton`, `invariants.meshTriangles` — and the dotted path in the cut manifest (`crop.h`) and in the motion spec (`` `animations.idle.tracks[0].keys[1].v[0]` ``, after the motion spec's own field checks, which keep their sentences). ⚠️ It sees numbers only: `"x": "NaN"` is a string, and the row above refuses it as one |
6133
6140
  | `two bones are called "X"` | bone names are the join key; rename one |
6134
6141
  | `two ik constraints are called "X" — a constraint resolves by name AND type (\`SkeletonData.findConstraint\`), so names are unique PER KIND: an ik and a transform constraint may share one, two of a kind may not` | §3.5 — rename one of the two. The kind in the sentence is the pair's own, so `two transform constraints are called "X"` is the same refusal on another kind; a name shared **across** kinds is not this error |
6142
+ | `ik constraint "X" names 3 bones ("a", "b", "c"); the solver applies one or two, so a constraint over 3 moves nothing — name one bone, or two where the second is the first's child` | §3.5 — the constraint's `bones` is what changes: one bone, or a parent and its child. The runtime's ik update has a case for one bone and one for two, so a longer list loads, sits in the update cache and applies nothing — [measured] every bone posed exactly where the same rig with no constraint has it. A longer chain is several constraints, each over one bone or a pair |
6143
+ | `ik constraint "X": "B" is not a child of "A" ("C" stands between), so the two-bone solve is not of the chain drawn — name "B"'s own parent as the first bone, or make "B" a child of "A"` (also `("A" is not above it; its parent is "P")`, `("B" has no parent)`) | §3.5 — the constraint's `bones` is what changes, or `B`'s `parent`. The two-bone solve places `B` through `A`'s matrix from `B`'s own local offset, so what stands between them is left out of the triangle it solves — [measured] a bone between them at `B`'s origin left the tip up to 2.60 off the target, and one at `A`'s origin landed only while it held its identity |
6135
6144
  | `physics constraint "X" is declared in both the rig spec and the motion spec's physics table` | §4.6 — the rig spec declares a physics constraint's structure and the motion spec's `physics` table declares one outright; pick the file it belongs in. Per kind, like every other constraint name: an `ik` "X" in the rig spec beside a `physics` "X" here is two constraints and is not this error |
6136
6145
  | `skin "S" lists "X" under "transform", but the rig declares it as a "ik" constraint — a skin looks its constraints up by name AND type, so this one is a miss and the loader throws` | §3.4.1 — move the name to the list named after the constraint's own kind. A name the rig declares under **no** kind is the other miss and says so (`skin "S" activates ik constraint "X", which this rig does not declare`). ⚠️ A name listed by **several skins** is not an error: the lists are per-skin sets and a bone or constraint in two of them is active under either |
6137
6146
  | `slot "X" names bone "Y", which this rig does not declare` | add the bone, or fix the slot's `bone` |
@@ -6409,7 +6418,7 @@ Fix A00 and run it again.
6409
6418
  | `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 |
6410
6419
  | `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, 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 not measured 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. **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
6411
6420
  | `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. **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 — 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 |
6412
- | `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. 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, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "ik", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused: `ik constraint "C" is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — … — so the declaration exempts nothing; drop the entry`. **SKIP** when the skeleton declares no ik constraint, and **SKIP by name** when every ik constraint it declares is declared consumer-driven: `every ik constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the rule measures that one and the stats line names the declared (`ikConsumerDriven`) |
6421
+ | `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. 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, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "ik", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused: `ik constraint "C" is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — … — so the declaration exempts nothing; drop the entry`. 🔩 **The shape clause** (issue #1205) reads `bones` whatever the mix, declared or not: an ik over more than two bones — `ik constraint "C" names 3 bones ("a", "b", "c"); the solver applies one or two, so a constraint over 3 moves nothing — name one bone, or two where the second is the first's child` — and a pair whose second bone's parent is not the first — `ik constraint "C": "b" is not a child of "a" ("m" stands between), so the two-bone solve is not of the chain drawn — name "b"'s own parent as the first bone, or make "b" a child of "a"`, or `("a" is not above it; its parent is "p")` when `a` is not an ancestor at all. [measured, rigc's fixtures through spine-core] the three-bone and four-bone ik posed every bone exactly where no constraint does; a pair skipping a bone missed by 18.95–22.25, a pair with a bone between at the child's origin by 0.27–2.60, and one at the parent's origin landed only while that bone held its identity (turned 15°: 3.50–4.11 off). The fix is the rig spec's `bones` (§3.5), and a rig spec states neither shape — the parser refuses both with the same sentence (§5.1) — so this clause is for a file a rig spec did not write. On the core entry the model document's reader refuses both first, as `A00_MODEL_READ`. **SKIP** when the skeleton declares no ik constraint, and **SKIP by name** when every ik constraint it declares is declared consumer-driven: `every ik constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the rule measures that one and the stats line names the declared (`ikConsumerDriven`) |
6413
6422
  | `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. 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, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "transform", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused as `A47`'s is; one that drives no property is refused with its own sentence declared or not, since no mix it carries is read by anybody. **SKIP** when the skeleton declares no transform constraint, and **SKIP by name** when every transform constraint it declares is declared consumer-driven: `every transform constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the stats line names the declared (`transformConsumerDriven`) |
6414
6423
  | `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` | renderer | two regions on one page whose rectangles overlap **and** whose footprints overlap — so one drawing samples texels that are the other's. A region's **footprint** is what samples it: a region a mesh names (its `path`, else its name, every frame of a `sequence` included) draws the mesh's hull loop and its triangles, its UVs placed on the page as `MeshAttachment.computeUVs` places them (a **linked mesh** draws its source's); a region a region attachment names, or that nothing names, draws its whole rectangle — and so does a mesh region whose UVs leave 0..1, whose hull or triangles do not index its vertices, or that is turned by `rotate:`, and every region when a `sequence` cannot be read. For two rectangles that is exactly the clause `A06` held until issue #1099 — an editor alias, a sequence frame, two rectangles moved over the same texels are refused as they were — and it accepts what `--pack-shape polygon` writes: a neighbour inside a mesh's rectangle where the hull is not (§0.1). **Touching is not overlapping**: interiors are compared, every comparison `FOOTPRINT_SLACK` (1e-6 texels) in favour of apart. `regions "ear_l" (341,696 112x420) and "mouth" (332,709 96x48) overlap on page "skeleton.png", and so do their footprints — "ear_l" draws the hull of mesh "ear_l" (skin "default", slot "ear_l"), "mouth" draws its whole rectangle, which region attachment "mouth" (skin "default", slot "mouth") draws; what they draw overlaps by 1916.5512 texel(s) of area inside 347,709 81x48. One drawing samples texels that are the other's: move one region clear of the other's footprint, or pack the page again` — `gallery/nod`'s polygon pack with `mouth` moved onto a triangle of the `ear_l` mesh (`PK88`). Nothing about the **padding** is read — the atlas and the model document do not state it — and the `2 · padding` the packer keeps is the packer's own (`PK81`). A renderer rule because two regions over the same texels is what an editor's packer writes for an alias or a sequence: 49 pairs on four of the ten atlases in `examples/`. **SKIP** when the atlas declares no page or no region, and when no page carries two regions — every unpacked build |
6415
6424
  | `A50_STAGE_BOX_IS_THE_STAGE` | both | the stage box a rig asked for (`skeleton.stageBox`, §3.1) is not the stage the model document states. Read on the data a consumer reads and on the pose it reads: the slot carries, in the `default` skin under the asked name, an unweighted bounding box of four vertices whose **stored** vertices are the stage's corners exactly — `(x, y)`, `(x + w, y)`, `(x + w, y + h)`, `(x, y + h)`, as the float32 the runtime loads — and whose **world** vertices at the setup pose (constraints applied, no physics, no skin set) are those corners as the runtime poses them on an unrotated root, compared on the header's 1e-6 grid at float32. `the stage box (slot "stage", attachment "stage"): vertex 1 is stored as (65, 0) and the stage's corner is (64, 0) — the stage 0,0 64x48, bottom-left first and counter-clockwise`; a root moved at setup reads *vertex 0 poses at (1, 0) at the setup pose and the stage's corner poses at (0, 0) on an unrotated root, on the header's grid — something moves the box's bone at setup (a constraint, or a pose the root does not state)*; a slot the skeleton lacks, no attachment of that name, another type, or a weighted box are each named too. Through spine-core it reads `findSlot`, `defaultSkin.getAttachment` and `computeWorldVertices` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side the document's record and rigc's core poser, held to spine-core's poses at tolerance 0 (`PG06`, `PG07`). **SKIP** where nothing asks: *nothing asks for a stage box: no rigc-compiled/3 model document beside this skeleton states one …* — a rig without the field, and any skeleton with no `/3` document beside it, an export among them |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.14.1",
3
+ "version": "2.15.1",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,16 +14,83 @@
14
14
  *
15
15
  * Moved out of `src/validate.ts` whole: the bones, the target, the setup mix
16
16
  * and the keyed ones are the rig's, the declaration the rig spec's.
17
+ *
18
+ * 🔩 A second clause reads the constraint's `bones` as a shape, whatever its
19
+ * mix (issue #1205): more than two bones, or a pair whose second bone's
20
+ * parent is not the first, is a constraint the solver cannot apply as drawn
21
+ * — `ikShapeFault` below, whose sentence the rig-spec parser prints too. A
22
+ * clause rather than an assertion of its own because it asks this body's
23
+ * question of the same object — does this ik do what it says — and a new
24
+ * registry entry would move every count the documents state.
17
25
  */
18
26
  import type { Verdicts } from '../harness.ts';
19
27
  import type { ConstraintFacts } from '../facts/constraints.ts';
20
28
  import { consumerDriven, consumerSkip, declaredButLive, declareIt, ikLive, noneKeysItsMixAbove0, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
21
29
  import type { RigInfo } from '../../types.ts';
22
30
 
31
+ /**
32
+ * What is wrong with an ik constraint's `bones` as a shape, in the sentence
33
+ * both the rig-spec parser and A47 print — or `null` when the shape is one
34
+ * the solver applies as drawn (issue #1205).
35
+ *
36
+ * - **A count other than one or two moves nothing.** The runtime's update
37
+ * switches on the count with a case for one bone and one for two, so a
38
+ * constraint over three or more is in the update cache and applies nothing
39
+ * [measured: a chain under an ik over three bones and over four posed every
40
+ * bone exactly where the same rig with no constraint did, 49 samples,
41
+ * tolerance 0]. A list of none is refused before this (the compiler's
42
+ * "needs a non-empty bones array"; an export's own loader).
43
+ * - **A pair whose second bone's parent is not the first solves another
44
+ * triangle.** The two-bone solve places the second bone through the first's
45
+ * matrix from the second's own local offset, so whatever stands between
46
+ * them is left out of the chain it solves [measured on the same chain: a
47
+ * bone between them at the child's origin left the tip 0.27–2.60 off the
48
+ * target, a pair skipping a bone 18.95–22.25 off, and a zero-offset bone
49
+ * between them at the parent's origin landed only while it held its
50
+ * identity — turned 15° in setup, 3.50–4.11 off in every frame]. The rule
51
+ * reads the parent and not the pose, because a pose that happens to agree
52
+ * is not a rig that does. rigc's own reader of the model document refuses
53
+ * both shapes too (`A00_MODEL_READ` on the core entry); this is the same
54
+ * refusal on the side that reads a Spine file.
55
+ *
56
+ * `secondAncestors` is the second bone's ancestors, parent first, as the
57
+ * facts carry them.
58
+ */
59
+ export function ikShapeFault(name: string, bones: readonly string[], secondAncestors: readonly string[]): string | null {
60
+ const quoted = (list: readonly string[]): string => list.map((b) => `"${b}"`).join(', ');
61
+ if (bones.length > 2) {
62
+ return (
63
+ `ik constraint "${name}" names ${bones.length} bones (${quoted(bones)}); the solver applies one or two, so a constraint ` +
64
+ `over ${bones.length} moves nothing — name one bone, or two where the second is the first's child`
65
+ );
66
+ }
67
+ if (bones.length !== 2 || secondAncestors[0] === bones[0]) return null;
68
+ const [first, second] = bones;
69
+ const at = secondAncestors.indexOf(first);
70
+ const why =
71
+ at > 0
72
+ ? `${quoted(secondAncestors.slice(0, at))} ${at === 1 ? 'stands' : 'stand'} between`
73
+ : secondAncestors.length === 0
74
+ ? `"${second}" has no parent`
75
+ : `"${first}" is not above it; its parent is "${secondAncestors[0]}"`;
76
+ return (
77
+ `ik constraint "${name}": "${second}" is not a child of "${first}" (${why}), so the two-bone solve is not of the ` +
78
+ `chain drawn — name "${second}"'s own parent as the first bone, or make "${second}" a child of "${first}"`
79
+ );
80
+ }
81
+
23
82
  export function a47IkConstraintNotMutedThroughout({ fail, skip, stats }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
24
83
  const NAME = 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT';
25
84
  const constraints = facts.constraints.flatMap((c, index) => (c.ik === undefined ? [] : [{ name: c.name, ik: c.ik, index }]));
26
85
  if (!constraints.length) return skip(NAME, 'the skeleton declares no ik constraint');
86
+ // The shape first, whatever the mix: a constraint the solver cannot apply as drawn is not exempted by a declaration of who sets its mix.
87
+ let shapeFaults = 0;
88
+ for (const { name, ik } of constraints) {
89
+ const fault = ikShapeFault(name, ik.bones, ik.secondAncestors);
90
+ if (fault === null) continue;
91
+ shapeFaults++;
92
+ fail(NAME, fault);
93
+ }
27
94
  // Channel 0 is `mix`; the other four are softness, bend direction, compress and stretch.
28
95
  const ikSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'ik', 1, (_timeline, value, channel) => channel === 0 && ikLive(value));
29
96
  const declared = consumerDriven(rig, 'ik');
@@ -55,5 +122,5 @@ export function a47IkConstraintNotMutedThroughout({ fail, skip, stats }: Verdict
55
122
  );
56
123
  }
57
124
  if (exempt.length) stats.ikConsumerDriven = exempt.map(([name]) => name).join(',');
58
- if (exempt.length === constraints.length) return skip(NAME, consumerSkip('ik', exempt, facts.animations));
125
+ if (exempt.length === constraints.length && shapeFaults === 0) return skip(NAME, consumerSkip('ik', exempt, facts.animations));
59
126
  }
@@ -79,7 +79,19 @@ export interface ConstraintEntry {
79
79
  readonly scale: number;
80
80
  readonly mix: number;
81
81
  };
82
- readonly ik?: { readonly bones: readonly string[]; readonly target: string; readonly mix: number };
82
+ readonly ik?: {
83
+ readonly bones: readonly string[];
84
+ readonly target: string;
85
+ readonly mix: number;
86
+ /**
87
+ * For an ik over exactly two bones, the second bone's ancestors, its
88
+ * parent first and the root last; empty for any other count (issue
89
+ * #1205). The two-bone solve places the second bone through the first's
90
+ * matrix from the second's own local offset, so it is the solve of the
91
+ * chain drawn only when the first entry here is the first bone.
92
+ */
93
+ readonly secondAncestors: readonly string[];
94
+ };
83
95
  /**
84
96
  * A transform constraint's six mix channels in frame order — `mixRotate`,
85
97
  * `mixX`, `mixY`, `mixScaleX`, `mixScaleY`, `mixShearY` — each the field's
@@ -125,8 +125,14 @@ function entryOf(doc: CompiledDocument, kind: ConstraintKind, name: string, reco
125
125
  },
126
126
  };
127
127
  }
128
- case 'ik':
129
- return { ...base, ik: { bones: record.bones, target: record.target, mix: record.mix } };
128
+ case 'ik': {
129
+ // The second bone's ancestors, parent first (issue #1205). The core's reader refuses a pair that is not a parent and its child, so on this side the first entry is always the first bone.
130
+ const secondAncestors: string[] = [];
131
+ if (record.bones.length === 2) {
132
+ for (let at = doc.bones.find((b) => b.name === record.bones[1])?.parent; at !== undefined; at = doc.bones.find((b) => b.name === at)?.parent) secondAncestors.push(at);
133
+ }
134
+ return { ...base, ik: { bones: record.bones, target: record.target, mix: record.mix, secondAncestors } };
135
+ }
130
136
  case 'transform':
131
137
  return {
132
138
  ...base,
package/src/mesh.ts CHANGED
@@ -1075,7 +1075,24 @@ export function traceAlphaOutline(
1075
1075
  const inside = new Uint8Array(w * h);
1076
1076
  for (let i = 0; i < inside.length; i++) inside[i] = label[i] === biggest ? 1 : 0;
1077
1077
 
1078
- const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : inside[y * w + x]);
1078
+ // The silhouette the walk traces is the island WITH ITS HOLES FILLED, and it is
1079
+ // built before the pinch scan because the scan has to read the same pixels the
1080
+ // walk does. A corner where two holes meet diagonally is inside the filled
1081
+ // silhouette, and the outer outline never visits it; scanning the island
1082
+ // before the fill refused such a part for a pinch no outline passes through
1083
+ // (issue #1209).
1084
+ //
1085
+ // ⚠️ A hole is background that no 8-connected path of background reaches from
1086
+ // the border, not merely no 4-connected one. The island is 4-connected, so two
1087
+ // background pixels that touch only at a corner are connected through it — the
1088
+ // same duality the walk's clockwise rule keeps. With a 4-connected flood a
1089
+ // transparent pixel that touches the outside only diagonally would read as a
1090
+ // hole, the fill would close the pinch at that corner, and the part would trace
1091
+ // a region the art does not enclose: that is filling a pinch, not a hole. On
1092
+ // every part this function accepts the two floods fill the same pixels — they
1093
+ // can only differ across a corner the scan below refuses.
1094
+ const { filled, holePixels } = fillEnclosed(inside, w, h, 8);
1095
+ const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : filled[y * w + x]);
1079
1096
  /** Outgoing cracks at one lattice corner, as direction indices. */
1080
1097
  const outgoing = (cx: number, cy: number): number[] => {
1081
1098
  const dirs: number[] = [];
@@ -1087,12 +1104,17 @@ export function traceAlphaOutline(
1087
1104
  };
1088
1105
 
1089
1106
  // A DIAGONAL PINCH is refused before the walk, not during it. At a corner where
1090
- // two art pixels meet diagonally with background on the other diagonal, two
1091
- // cracks leave the same point — so the boundary is not a set of simple loops
1092
- // any more, and whichever pairing the walk chooses it either passes through one
1093
- // point twice or leaves part of the outline untraced. Both are silent: the
1094
- // second one produces a perfectly valid mesh of the wrong region. Scanning for
1095
- // it costs one pass and names the pixel corner.
1107
+ // two pixels of the filled silhouette meet diagonally with background on the
1108
+ // other diagonal, two cracks leave the same point — so the boundary is not a
1109
+ // set of simple loops any more, and whichever pairing the walk chooses it
1110
+ // either passes through one point twice or leaves part of the outline
1111
+ // untraced. Both are silent: the second one produces a perfectly valid mesh of
1112
+ // the wrong region. Scanning for it costs one pass and names the pixel corner.
1113
+ // Background here is what the fill left, so both pixels on that diagonal are
1114
+ // reached from outside the part: the corner is on the outer outline itself.
1115
+ // The remedy names a direction for each outcome because a threshold moves art
1116
+ // one way only: raising it removes pixels, which can open a pinch and never
1117
+ // close one, and lowering it adds them.
1096
1118
  for (let cy = 0; cy <= h; cy++) {
1097
1119
  for (let cx = 0; cx <= w; cx++) {
1098
1120
  const tl = at(cx - 1, cy - 1);
@@ -1102,16 +1124,19 @@ export function traceAlphaOutline(
1102
1124
  if ((tl && br && !tr && !bl) || (tr && bl && !tl && !br)) {
1103
1125
  throw new MeshError(
1104
1126
  `the alpha silhouette pinches to a single point at pixel corner (${cx},${cy}), where two parts of the art ` +
1105
- 'meet diagonally — one outline cannot pass through one point twice. Raise the alpha threshold so the ' +
1106
- 'pinch closes or opens, or author the geometry as weights',
1127
+ 'meet diagonally — one outline cannot pass through one point twice. Move the alpha threshold so the ' +
1128
+ 'pinch opens (raise it) or closes (lower it), or author the geometry as weights',
1107
1129
  );
1108
1130
  }
1109
1131
  }
1110
1132
  }
1111
1133
 
1134
+ // The first filled pixel in row order is an island pixel: a hole's upper
1135
+ // neighbour is never background the border reaches, so no hole sits on the
1136
+ // silhouette's top row.
1112
1137
  let startAt = -1;
1113
- for (let i = 0; i < inside.length; i++) {
1114
- if (inside[i]) {
1138
+ for (let i = 0; i < filled.length; i++) {
1139
+ if (filled[i]) {
1115
1140
  startAt = i;
1116
1141
  break;
1117
1142
  }
@@ -1119,10 +1144,9 @@ export function traceAlphaOutline(
1119
1144
  const sx = startAt % w;
1120
1145
  const sy = (startAt - sx) / w;
1121
1146
  // With no pinch anywhere, every corner has at most one outgoing crack, so the
1122
- // boundary is a disjoint union of simple loops and this walk traces exactly
1123
- // one of them. Starting on the top edge of the first art pixel in row order
1124
- // puts it on the OUTER loop; a hole's loop is never entered, which is what
1125
- // makes "holes are filled" a property of the trace rather than a later repair.
1147
+ // boundary is a simple loop and this walk traces it. The holes have no loop
1148
+ // of their own to enter, because the walk reads the filled silhouette: their
1149
+ // pixels are inside the outline and draw nothing, and `holePixels` counts them.
1126
1150
  const outline: Array<[number, number]> = [];
1127
1151
  let cx = sx;
1128
1152
  let cy = sy;
@@ -1144,7 +1168,6 @@ export function traceAlphaOutline(
1144
1168
  dir = next;
1145
1169
  }
1146
1170
 
1147
- const { filled, holePixels } = fillEnclosed(inside, w, h);
1148
1171
  return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
1149
1172
  }
1150
1173
 
@@ -1155,8 +1178,19 @@ export function traceAlphaOutline(
1155
1178
  * than by a winding rule — which is what makes it answer the same question for a
1156
1179
  * mask of one island and a mask of several, and is why the authored-mesh
1157
1180
  * measurement can pass it all the art where the trace passes it one island.
1181
+ *
1182
+ * `background` is how the flood steps: 4 through edges, 8 through corners too.
1183
+ * The trace floods 8 — the dual of its 4-connected island, so background that
1184
+ * meets the outside at a corner is outside — and says why where it calls this.
1185
+ * The authored-mesh measurement floods 4, as it always has; the two agree on
1186
+ * every mask the trace accepts.
1158
1187
  */
1159
- function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8Array; holePixels: number } {
1188
+ function fillEnclosed(
1189
+ inside: Uint8Array,
1190
+ w: number,
1191
+ h: number,
1192
+ background: 4 | 8,
1193
+ ): { filled: Uint8Array; holePixels: number } {
1160
1194
  const outsideReach = new Uint8Array(w * h);
1161
1195
  const queue: number[] = [];
1162
1196
  const seed = (x: number, y: number): void => {
@@ -1182,6 +1216,12 @@ function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8
1182
1216
  seed(x + 1, y);
1183
1217
  seed(x, y - 1);
1184
1218
  seed(x, y + 1);
1219
+ if (background === 8) {
1220
+ seed(x - 1, y - 1);
1221
+ seed(x + 1, y - 1);
1222
+ seed(x - 1, y + 1);
1223
+ seed(x + 1, y + 1);
1224
+ }
1185
1225
  }
1186
1226
  const filled = new Uint8Array(w * h);
1187
1227
  let holePixels = 0;
@@ -1340,7 +1380,7 @@ export function measureAuthoredMeshFit(
1340
1380
  ): MeshFitReport {
1341
1381
  const { width: w, height: h } = mask;
1342
1382
  const art = artOf(mask, threshold);
1343
- const { filled } = fillEnclosed(art, w, h);
1383
+ const { filled } = fillEnclosed(art, w, h, 4);
1344
1384
  const covered = rasteriseTriangles(points, triangles, w, h);
1345
1385
  let artPixels = 0;
1346
1386
  let coveredArt = 0;
package/src/rig.ts CHANGED
@@ -59,6 +59,7 @@
59
59
  * `slots` below for the join rule) and leaves `skins` empty. A foreign skeleton
60
60
  * with no manifest at all declares them here.
61
61
  */
62
+ import { ikShapeFault } from './assertions/bodies/a47.ts';
62
63
  import { CompileError, NotImplementedError } from './errors.ts';
63
64
  import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
64
65
  import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
@@ -1352,7 +1353,13 @@ export type RigScaleYMode = 'none' | 'uniform' | 'volume' | 'None' | 'Uniform' |
1352
1353
  /** `type: "ik"` (`:149-176`). `scaleY` is 4.3's replacement for 4.2's `uniform`. */
1353
1354
  export interface RigIkConstraint extends RigConstraintCommon {
1354
1355
  type: 'ik';
1355
- /** At least one, resolved by name; a miss throws in the parser. */
1356
+ /**
1357
+ * One bone, or two where the second's parent is the first; resolved by
1358
+ * name, and a miss throws in the parser. Any other shape is refused by name
1359
+ * (issue #1205): the runtime applies nothing for three or more, and solves
1360
+ * a pair through the first bone's matrix from the second's local offset, so
1361
+ * a bone standing between them is left out of the triangle it solves.
1362
+ */
1356
1363
  bones: string[];
1357
1364
  target: string;
1358
1365
  /** `ConstraintData.ts:50`. Absent → `None`. */
@@ -2682,6 +2689,20 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2682
2689
  constraintsDeclared.push(declared);
2683
2690
  constraintFacts.set(constraintAt(declared.type, declared.name), declared);
2684
2691
  constraintKinds.set(declared.name, [...(constraintKinds.get(declared.name) ?? []), declared.type]);
2692
+ // An ik's `bones` as a shape (issue #1205): more than two, or a pair that is
2693
+ // not a parent and its child, both load and build green and the solver then
2694
+ // moves nothing or solves a triangle nobody drew — `ikShapeFault` says which.
2695
+ // Read only once every name resolves to a declared bone, so a misspelling is
2696
+ // still the compiler's refusal by name rather than a shape it does not have.
2697
+ const ikBones = constraint.type === 'ik' ? constraint.bones : undefined;
2698
+ if (Array.isArray(ikBones) && ikBones.every((b): b is string => typeof b === 'string' && seen.has(b))) {
2699
+ const secondAncestors: string[] = [];
2700
+ if (ikBones.length === 2) {
2701
+ for (let at = spec.bones.find((b) => b.name === ikBones[1])?.parent; at !== undefined; at = spec.bones.find((b) => b.name === at)?.parent) secondAncestors.push(at);
2702
+ }
2703
+ const fault = ikShapeFault(constraint.name, ikBones, secondAncestors);
2704
+ if (fault !== null) throw new CompileError(`${where}: ${fault}`);
2705
+ }
2685
2706
  }
2686
2707
 
2687
2708
  // `invariants.consumerDrivenMix` — the second field in `invariants` that TURNS
package/src/validate.ts CHANGED
@@ -1641,7 +1641,9 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
1641
1641
  };
1642
1642
  }
1643
1643
  if (c instanceof IkConstraintData) {
1644
- return { kind: 'ik', name: c.name, runtimeClass, ik: { bones: c.bones.map((b) => b.name), target: c.target.name, mix: c.setupPose.mix } };
1644
+ const secondAncestors: string[] = [];
1645
+ if (c.bones.length === 2) for (let at = c.bones[1].parent; at !== null; at = at.parent) secondAncestors.push(at.name);
1646
+ return { kind: 'ik', name: c.name, runtimeClass, ik: { bones: c.bones.map((b) => b.name), target: c.target.name, mix: c.setupPose.mix, secondAncestors } };
1645
1647
  }
1646
1648
  if (c instanceof TransformConstraintData) {
1647
1649
  const pose = c.setupPose;
@@ -20,6 +20,22 @@
20
20
  * It ran from a shell script in a local scratch directory. A tool nobody can
21
21
  * find is not a tool, hence this file.
22
22
  *
23
+ * ## Which editor the report names
24
+ *
25
+ * Step 0's version line is the one place the report says which editor the trip
26
+ * ran on. It is read from the editor's `--version` started with the SAME `-u`
27
+ * the import and the export carry (`--editor-version`), so a pinned run names
28
+ * the pinned editor, and says so on that line: `(read from --version under -u
29
+ * <v>, …)`. An unpinned run prints the line alone, read from `--version` with no
30
+ * `-u` — the editor the launcher starts by default. Once the export exists,
31
+ * step 2 holds that version against the export's own `skeleton.spine`, prints
32
+ * one line saying which it read and that the two agree, and when they DISAGREE
33
+ * prints a `FAIL` naming both values and ends the run non-zero: a version line
34
+ * that names an editor the trip did not export on is a fault in the instrument,
35
+ * and a green run carrying it would be a measurement naming the wrong
36
+ * instrument (issue #1199, where a bare `--version` named the launcher's
37
+ * default, 4.3.26, on a trip pinned to and exported by 4.3.23).
38
+ *
23
39
  * ## What it is not
24
40
  *
25
41
  * 🔒 **It is not a way to get Spine data without the editor** — it is the
@@ -182,10 +198,82 @@ function bundleName(plist: string): string | null {
182
198
  */
183
199
  const EDITOR_VERSION_LINE = /^Spine[ \t]+\d+\.\d+\.\d+\b[^\n]*$/m;
184
200
 
185
- /** What step 0 prints for the editor's version: its own line, or a sentence saying it was not there. */
186
- function editorVersionLine(output: string): string {
187
- const line = EDITOR_VERSION_LINE.exec(output);
188
- return line === null ? 'editor version: not found in --version output' : line[0].trim();
201
+ /**
202
+ * What step 0 prints for the editor's version — its own line, or a sentence
203
+ * saying it was not there — and the `x.y.z` that line names, which is what the
204
+ * export's own `skeleton.spine` is checked against once the export exists
205
+ * (issue #1199). `version` is null exactly when `line` is the absence.
206
+ */
207
+ function editorVersion(output: string): { line: string; version: string | null } {
208
+ const found = EDITOR_VERSION_LINE.exec(output);
209
+ if (found === null) return { line: 'editor version: not found in --version output', version: null };
210
+ return { line: found[0].trim(), version: /^Spine[ \t]+(\d+\.\d+\.\d+)/.exec(found[0])?.[1] ?? null };
211
+ }
212
+
213
+ /**
214
+ * Where step 0 read the editor's version, in the words the report prints it in
215
+ * (issue #1199): the `--version` call, and the `-u` it carried or the absence of
216
+ * one — which on an unpinned run means the editor the launcher starts by default.
217
+ */
218
+ function versionSource(pinned: string | null): string {
219
+ return pinned === null
220
+ ? "read from --version with no -u: the editor the launcher starts by default"
221
+ : `read from --version under -u ${pinned}, the pin every editor call of this trip carries`;
222
+ }
223
+
224
+ /**
225
+ * The `skeleton.spine` an export declares, or null when it declares none or the
226
+ * file cannot be read as a skeleton — read off the FILE, as `shapeOf` reads it,
227
+ * because it is the editor's own statement of the data version it exported.
228
+ * Null is not swallowed: the caller states that nothing was there to check.
229
+ */
230
+ function declaredSpineVersion(path: string): string | null {
231
+ try {
232
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
233
+ if (!isRecord(parsed) || !isRecord(parsed.skeleton)) return null;
234
+ const spine = parsed.skeleton.spine;
235
+ return typeof spine === 'string' ? spine : null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The line step 2 prints once the export exists: the version step 0 read held
243
+ * against the export's own `skeleton.spine` (issue #1199). `fail` is true only
244
+ * when both are there and differ — a disagreement is a finding about the
245
+ * INSTRUMENT, not a pass, so it fails the trip; a side with nothing to compare is
246
+ * stated and fails nothing, the way an absent version line has never been a
247
+ * refusal (#1077).
248
+ */
249
+ export function versionAgreement(
250
+ read: string | null,
251
+ pinned: string | null,
252
+ declared: string | null,
253
+ ): { line: string; fail: boolean } {
254
+ const source = versionSource(pinned);
255
+ if (declared === null) {
256
+ return {
257
+ line: ` the export declares no skeleton.spine, so the version step 0 ${read === null ? 'did not find' : `read (${read}, ${source})`} is not checked against it`,
258
+ fail: false,
259
+ };
260
+ }
261
+ if (read === null) {
262
+ return {
263
+ line: ` the export declares skeleton.spine "${declared}"; step 0 found no version line (${source}) to check it against`,
264
+ fail: false,
265
+ };
266
+ }
267
+ if (read === declared) {
268
+ return { line: ` the export declares skeleton.spine "${declared}", the version step 0 read (${source})`, fail: false };
269
+ }
270
+ return {
271
+ line:
272
+ ` FAIL step 0 read the editor as ${read} (${source}) and the export declares skeleton.spine "${declared}" — ` +
273
+ 'the version line names an editor this trip did not export on, which is a fault in the instrument rather ' +
274
+ 'than in the rig, so the trip is not green',
275
+ fail: true,
276
+ };
189
277
  }
190
278
 
191
279
  /** The trial naming itself in its own `--version` output, or null. */
@@ -1286,6 +1374,10 @@ function main(): void {
1286
1374
  emit(` ${run(rigc.cmd, [...rigc.prefix, '--version'], 60).stdout.trim()}`);
1287
1375
 
1288
1376
  let exportedJson: string;
1377
+ /** The `x.y.z` step 0 read off the editor, held against the export at step 2 (issue #1199). */
1378
+ let editorRead: string | null = null;
1379
+ /** True when the export's `skeleton.spine` and step 0's version both exist and differ. */
1380
+ let versionDisagrees = false;
1289
1381
  if (opts.exported !== null) {
1290
1382
  exportedJson = opts.exported;
1291
1383
  emit(` editor NOT RUN — measuring an export the editor already made: ${opts.exported}`);
@@ -1309,16 +1401,26 @@ function main(): void {
1309
1401
  const named = trialSignalsFromPath(opts.editor);
1310
1402
  if (named.length > 0) fail(trialRefusal(opts.editor, named));
1311
1403
 
1312
- const ver = run(opts.editor, ['--version'], 60);
1404
+ const pin = opts.editorVersion === null ? [] : ['-u', opts.editorVersion];
1405
+ // 🚨 Under the same `-u` the import and the export carry (issue #1199). The
1406
+ // call used to be a bare `--version`, which names the editor the LAUNCHER
1407
+ // starts by default: measured 2026-10-07 on a launcher whose default is
1408
+ // 4.3.26, a trip pinned with `-u 4.3.23` exported `"spine": "4.3.23"` and
1409
+ // step 0 printed `Spine 4.3.26 Professional` — the one line that says which
1410
+ // editor the trip ran on, naming one it did not run on.
1411
+ const ver = run(opts.editor, [...pin, '--version'], 60);
1313
1412
  emit(` editor ${opts.editor}`);
1314
1413
  // One line, never the output's tail: the tail is the licensee's (issue #1077).
1315
- emit(` ${editorVersionLine(`${ver.stdout}\n${ver.stderr}`)}`);
1414
+ // A pinned run says where the line came from; an unpinned run prints the line
1415
+ // alone, as it always has, and step 2 states its source beside the export's.
1416
+ const stated = editorVersion(`${ver.stdout}\n${ver.stderr}`);
1417
+ editorRead = stated.version;
1418
+ emit(` ${stated.line}${opts.editorVersion === null ? '' : ` (${versionSource(opts.editorVersion)})`}`);
1316
1419
  // The second signal, and the only one that works on a platform whose trial
1317
1420
  // path this repository has never seen: the binary's own banner. It costs no
1318
1421
  // extra call — `--version` above is one the tool already made.
1319
1422
  const introduced = trialSignalFromVersion(`${ver.stdout}\n${ver.stderr}`);
1320
1423
  if (introduced !== null) fail(trialRefusal(opts.editor, [introduced]));
1321
- const pin = opts.editorVersion === null ? [] : ['-u', opts.editorVersion];
1322
1424
 
1323
1425
  emit('');
1324
1426
  emit('## 1 import (json -> project)');
@@ -1353,6 +1455,11 @@ function main(): void {
1353
1455
  );
1354
1456
  }
1355
1457
  exportedJson = join(opts.out, 'export', written[0]);
1458
+ // 🔒 The version line checked against what the trip measured, once there is
1459
+ // an export to read (issue #1199): the editor's own `skeleton.spine`.
1460
+ const agreement = versionAgreement(editorRead, opts.editorVersion, declaredSpineVersion(exportedJson));
1461
+ emit(agreement.line);
1462
+ versionDisagrees = agreement.fail;
1356
1463
  }
1357
1464
 
1358
1465
  // The candidate: the export beside the build's atlas and pages, which were
@@ -1518,7 +1625,9 @@ function main(): void {
1518
1625
  // The verdict is the gate's and EVERY skin's check, not this tool's opinion of
1519
1626
  // them: one skin coming back wrong is the whole run coming back wrong, which
1520
1627
  // is the half a single un-skinned check could not say.
1521
- process.exit(gate.status === 0 && checksClean && rosterClean ? 0 : 1);
1628
+ // A version line that disagrees with the export is red too (issue #1199): the
1629
+ // report would otherwise name an editor the trip did not run on, green.
1630
+ process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees ? 0 : 1);
1522
1631
  }
1523
1632
 
1524
1633
  // ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be