spine-rigc 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -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.0",
3
+ "version": "2.15.0",
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,
@@ -13,7 +13,15 @@
13
13
  * rather than refusing it. It is held equal to spine-core's walk at
14
14
  * tolerance 0 by `tools/core_gate.ts`'s walk block and the selftest's
15
15
  * `CO25`, and the poses A10 asks for here are compared with spine-core's on
16
- * every call with a model in hand (`VF13`).
16
+ * every call with a model in hand (`VF13`). Since issue #1179's second part
17
+ * the poses come through that walk's scan assembler (`scanWalkSetup`,
18
+ * `scanLoopingWalk`): the same walk, each pose carrying what A10 reads — the
19
+ * bones' matrix and origin, the slot rows, the drawn attachments' slot, name
20
+ * and vertices — and none of the bones' getter readings and oracle rows or
21
+ * the drawn rows' UVs, triangles and colour, which nothing it reads is
22
+ * computed from. The core suite's `CO43` holds the scan to the public walk's
23
+ * numbers bit for bit, `CO44` what it leaves out to move none of them, and
24
+ * `CO45` that the vertex arrays it hands on uncopied are each pose's own.
17
25
  * - **A bone's mode**: the mode the core's bone timelines pose at the time
18
26
  * (`posedBones`, a fresh non-looping track: the time held at the duration),
19
27
  * folded as the runtime's lookup folds it (`foldInheritMode`). `readModel`
@@ -30,7 +38,8 @@
30
38
  import { fileAnimationOrder } from '../../compile.ts';
31
39
  import { foldInheritMode, type CompiledDocument } from '../../core/index.ts';
32
40
  import { posedBones } from '../../core/animation.ts';
33
- import { poseLoopingWalk, poseWalkSetup, type WalkPose } from '../../core/walk.ts';
41
+ import { scanLoopingWalk, scanWalkSetup } from '../../core/walk.ts';
42
+ import type { ScanPose } from '../../core/raw.ts';
34
43
  import { noSkinView } from '../../render_core.ts';
35
44
  import type { ModelBone } from '../../model.ts';
36
45
  import type { SteppedFrame, SteppedPoseFacts } from '../facts/stepped_poses.ts';
@@ -45,11 +54,11 @@ const channel = (v: number | null): number => (v === null ? Number.NaN : v);
45
54
  /** Each bone's posed mode as the facts spell it (`modeOf`), by name. */
46
55
  const modesOf = (posed: readonly ModelBone[]): ReadonlyMap<string, string | null> => new Map(posed.map((b) => [b.name, modeOf(b)]));
47
56
 
48
- /** One walk pose as A10 reads it, each bone's mode taken from `modes` (`modesOf` the bones the timelines posed at the pose's time). */
49
- function frameOf(pose: WalkPose, modes: ReadonlyMap<string, string | null>): SteppedFrame {
57
+ /** One scan pose as A10 reads it, each bone's mode taken from `modes` (`modesOf` the bones the timelines posed at the pose's time); the drawn attachments are the scan's own rows, already in the shape A10 reads. */
58
+ function frameOf(pose: ScanPose, modes: ReadonlyMap<string, string | null>): SteppedFrame {
50
59
  return {
51
60
  bones: pose.bones.map((b) => ({ name: b.name, a: b.a, b: b.b, c: b.c, d: b.d, worldX: b.worldX, worldY: b.worldY, inherit: modes.get(b.name) ?? null })),
52
- drawn: pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: d.vertices })),
61
+ drawn: pose.drawn,
53
62
  slots: pose.slots.map((row) => ({
54
63
  name: row[0],
55
64
  colour: [channel(row[2]), channel(row[3]), channel(row[4]), channel(row[5])] as const,
@@ -82,10 +91,10 @@ export function modelSteppedPoses(read: ReadDocument): SteppedPoseFacts {
82
91
  },
83
92
  // The document's stated mode — asked only of a bone posing none, which the reader leaves this side no document to hold.
84
93
  statedInherit: (name) => `${JSON.stringify(read.doc.bones.find((b) => b.name === name)?.inheritMode)}`,
85
- setup: () => frameOf(poseWalkSetup(viewOf()), modesOf(viewOf().bones)),
94
+ setup: () => frameOf(scanWalkSetup(viewOf()), modesOf(viewOf().bones)),
86
95
  walk: (animation, step, frames) => {
87
96
  const anim = animationOf(animation);
88
- const poses = poseLoopingWalk(viewOf(), animation, new Array<number>(frames).fill(step));
97
+ const poses = scanLoopingWalk(viewOf(), animation, new Array<number>(frames).fill(step));
89
98
  // A bone's posed mode moves only by an `inherit` timeline: every other one `posedBones` reads leaves the setup's mode on its copy. So an animation
90
99
  // keying none poses the setup's modes at every time, and they are read once rather than posed again at each of its frames (issue #1179).
91
100
  const keysInherit = anim.timelines.bones.some((target) => target.timelines.some((tl) => tl.kind === 'inherit'));
@@ -797,6 +797,17 @@ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTi
797
797
  return { rows: stepRows(posed), world: posed.world };
798
798
  }
799
799
 
800
+ /**
801
+ * `posedBoneWorld`'s world transforms without its rows (issue #1179, the
802
+ * second part): the same pose by the same operations in the same order, since
803
+ * `stepRows` only reads the world once it is posed. A walk reads `world` and
804
+ * no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
805
+ * this; the raw entry's walk still forms the rows it does not read, unchanged.
806
+ */
807
+ export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: { ctx: PhysicsStepContext; before: number } | undefined): Map<string, CoreWorld> {
808
+ return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
809
+ }
810
+
800
811
  /**
801
812
  * One pose of the bones before its rows are read: the world transforms, and
802
813
  * what the rows are rounded from. A stepped walk reads rows only off the step
package/src/core/raw.ts CHANGED
@@ -114,14 +114,14 @@
114
114
  * missing would draw a different picture in silence.
115
115
  */
116
116
  import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
- import { posedBoneWorld, posedSlots, type TimelinePlant } from './animation.ts';
117
+ import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
119
  import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
121
121
  import { drawOrderAt } from './draw_order.ts';
122
122
  import { eventsFired } from './events.ts';
123
- import { REGION_TRIANGLES, REGION_UVS, type ShapeClipper } from './clipping.ts';
124
- import { poseGeometry, type ShownGeometry } from './vertices.ts';
123
+ import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
124
+ import { poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
125
125
  import { RUNTIME_DEG, type CoreWorld } from './world.ts';
126
126
  import type { SliderApplication } from './constraints_slider.ts';
127
127
 
@@ -195,6 +195,43 @@ export interface RawPose {
195
195
  // --- #968 render: end ---
196
196
  }
197
197
 
198
+ /**
199
+ * One bone as the scan reads it (issue #1179, the second part): the world
200
+ * matrix and origin `RawBone` carries, and none of the readings it forms from
201
+ * them. `A10_NO_NAN_AFTER_STEPPING` reads these six numbers and the name off a
202
+ * bone, and nothing else (`firstNonFinite` in `src/nonfinite.ts`).
203
+ */
204
+ export interface ScanBone {
205
+ name: string;
206
+ a: number;
207
+ b: number;
208
+ c: number;
209
+ d: number;
210
+ worldX: number;
211
+ worldY: number;
212
+ }
213
+
214
+ /**
215
+ * One pose as the scan reads it (issue #1179, the second part) — the scan's
216
+ * walk (`scanWalkIn`, `scanSetupIn`): the time the animation was applied at,
217
+ * and the bones, slot rows and drawn attachments computed exactly as a
218
+ * `RawPose`'s are. Internal to A10's walk (`./walk.ts`); no exported pose type
219
+ * changed for it.
220
+ */
221
+ export interface ScanPose {
222
+ animationTime: number;
223
+ bones: ScanBone[];
224
+ slots: CoreSlotRow[];
225
+ drawn: ScanDrawn[];
226
+ }
227
+
228
+ /** One region or mesh drawn, as the scan reads it: its slot, its name and its world vertices, as `RawDrawn` carries them. */
229
+ export interface ScanDrawn {
230
+ slot: string;
231
+ attachment: string;
232
+ vertices: number[];
233
+ }
234
+
198
235
  // --- #968 render: begin ---
199
236
  /** The shown records in `order`, the pose's draw order (a pose's `shown`). */
200
237
  function drawOrderOf(shown: readonly ShownGeometry[], order: readonly string[]): ShownGeometry[] {
@@ -274,17 +311,96 @@ function rawBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>):
274
311
  });
275
312
  }
276
313
 
277
- /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
278
- function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
314
+ /**
315
+ * The bones of a scan pose (`ScanBone`): `rawBones` less the parent, the
316
+ * active flag and the four getter readings, which nothing the scan reads is
317
+ * computed from — each reading is formed from the world after it is posed,
318
+ * and the matrix and origin are copied as they are.
319
+ */
320
+ function scanBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>): ScanBone[] {
321
+ return doc.bones.map((b): ScanBone => {
322
+ const w = world.get(b.name);
323
+ if (w === undefined) throw new CoreInputError(`bone "${b.name}" has no world transform`);
324
+ return { name: b.name, a: w.a, b: w.b, c: w.c, d: w.d, worldX: w.worldX, worldY: w.worldY };
325
+ });
326
+ }
327
+
328
+ /**
329
+ * What a pose draws, posed and refused where the core leaves it out: the
330
+ * attachment rows, the clip rows and the drawn clipped rows — what `rawDrawn`
331
+ * and `scanDrawn` both assemble from.
332
+ */
333
+ function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode): { rows: CoreAttachmentRow[]; clips: CoreClipRow[]; drawnClipped: CoreClippedRow[] } {
279
334
  const draw = drawWalkOf(doc, order, plant);
280
335
  const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
281
336
  if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
282
337
  // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
283
338
  if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
339
+ return { rows: geometry.attachments, clips: geometry.clips, drawnClipped: geometry.drawnClipped };
340
+ }
341
+
342
+ /**
343
+ * The drawn attachments of a scan pose (`ScanDrawn`): `rawDrawn`'s rows, each
344
+ * its slot, its name and its world vertices through the same `numberOf`, and
345
+ * none of what `rawDrawn` reads past them for a renderer — the UVs and
346
+ * triangles it copies, the record it looks up for the colour and the sequence
347
+ * frame, the hull, the clip rows' numbers. No vertex is computed from any of
348
+ * those. The one refusal `rawDrawn` makes past the rows, a linked mesh whose
349
+ * source carries no mesh, is not reachable here: `poseGeometry` resolved the
350
+ * same source by the same lookup (`sourceOfDoc`) and throws before it returns.
351
+ *
352
+ * 🔁 **No copy of the vertices** (issue #1179, the second part): `rawDrawn`
353
+ * maps each row through `numberOf` into a new array; under `keep` that map is
354
+ * the identity on every number and turns only a `null` into NaN. The scan
355
+ * hands the row's own array on wherever it holds no `null` (`allNumbers`) and
356
+ * maps it as before where it does, so A10 reads the same values either way.
357
+ * What makes the row's array safe to keep past the step — A10 collects an
358
+ * animation's 120 frames before it scans one — is that it is the pose's own:
359
+ * `poseGeometry` (`./vertices.ts`) builds every row's vertices with
360
+ * `.map(round)` over the corners or the skinned vertices it just computed, a
361
+ * new array per attachment per pose, held by nothing but the row. The core
362
+ * suite's `CO45` plants a poser that hands every pose the same array and reads
363
+ * the retained frames red.
364
+ */
365
+ function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanDrawn[] {
366
+ const { rows } = drawnGeometry(doc, shown, world, order, plant, mode);
367
+ const drawn: ScanDrawn[] = [];
368
+ let k = 0;
369
+ for (const s of shown) {
370
+ const g = s.geometry;
371
+ if (g.kind !== 'region' && g.kind !== 'mesh' && g.kind !== 'linkedmesh') continue;
372
+ const row = rows[k++];
373
+ if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
374
+ const vertices = row[3];
375
+ const kept = mode.keep && allNumbers(vertices) ? vertices : vertices.map((v) => numberOf(v, `vertex of slot "${s.slot}"`, mode));
376
+ drawn.push({ slot: s.slot, attachment: row[1], vertices: scanPlant.vertices === undefined ? kept : scanPlant.vertices(kept, s.slot) });
377
+ }
378
+ return drawn;
379
+ }
380
+
381
+ /** Whether a row's vertices hold no `null` — what `numberOf` would map — so the row's own array can be handed on as numbers. */
382
+ function allNumbers(values: Array<number | null>): values is number[] {
383
+ for (const v of values) if (v === null) return false;
384
+ return true;
385
+ }
386
+
387
+ /**
388
+ * The scan's one plant (the core suite's `CO45`): what a drawn row's vertices
389
+ * are handed on as, given the array the scan would hand on. A poser that
390
+ * reuses one buffer per slot across steps is the plant `CO45` passes.
391
+ * Nothing but a control passes one.
392
+ */
393
+ export interface ScanPlant {
394
+ vertices?: (vertices: number[], slot: string) => number[];
395
+ }
396
+
397
+ /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
398
+ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
399
+ const geometry = drawnGeometry(doc, shown, world, order, plant, mode);
284
400
  // Under a concave or inverse clip the oracle's `clipped` block is absent (the runtime's own triangle list); what the core draws is `drawnClipped`,
285
401
  // its own decomposition, and the render samples each drawn triangle at its source triangle's affine UV, so the pixels are the decomposition's
286
402
  // coverage alone (issue #964, `Mesh.source` in src/render.ts).
287
- const rows = geometry.attachments;
403
+ const rows = geometry.rows;
288
404
  const drawn: RawDrawn[] = [];
289
405
  let k = 0;
290
406
  for (const s of shown) {
@@ -326,14 +442,27 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
326
442
 
327
443
  /** `poseRawSetup` in `mode` — under `keep`, a value that is not finite stays in the pose (`./walk.ts`). */
328
444
  export function setupPoseIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): RawPose {
445
+ const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
446
+ const drawn = rawDrawn(doc, shown, world, drawOrder, plant, mode);
447
+ return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, drawOrder) };
448
+ }
449
+
450
+ /** `setupPoseIn` as the scan reads it (`ScanPose`): the same setup pose, the same refusals in the same order, assembled into the scan's pose. */
451
+ export function scanSetupIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanPose {
452
+ const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
453
+ const drawn = scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant);
454
+ return { animationTime: 0, bones: scanBones(doc, world), slots, drawn };
455
+ }
456
+
457
+ /** The setup pose `setupPoseIn` and `scanSetupIn` assemble, refused by name where the core leaves a block of it out. */
458
+ function setupPosed(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): { world: Map<string, CoreWorld>; shown: ShownGeometry[]; slots: CoreSlotRow[]; drawOrder: string[] } {
329
459
  const posed = poseSetup(doc, { ...plant, ...roundPlant(mode) }, freshStepContext(plant.physicsStep));
330
460
  // `setup.clipped` is the oracle's block, left out under a concave or inverse clip whose triangle list is the runtime's own; what the core draws there is `rawDrawn`'s to refuse or pose (issue #964).
331
461
  const absent = posed.absent.filter(([block]) => block !== 'setup.clipped');
332
462
  if (absent.length > 0) throw new CoreInputError(`the raw setup pose leaves ${absent.map(([b, why]) => `${b} out (${why})`).join('; ')}`);
333
463
  const { setup, world, shown } = posed;
334
464
  if (world === null || shown === null || setup.slots === null || setup.drawOrder === null) throw new CoreInputError('the raw setup pose was not posed');
335
- const drawn = rawDrawn(doc, shown, world, setup.drawOrder, plant, mode);
336
- return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots: setup.slots, drawOrder: setup.drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, setup.drawOrder) };
465
+ return { world, shown, slots: setup.slots, drawOrder: setup.drawOrder };
337
466
  }
338
467
 
339
468
  /**
@@ -361,7 +490,7 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
361
490
  * poses before it.
362
491
  */
363
492
  export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void): void {
364
- walkEach(doc, animation, steps, plant, reset, RAW_MODE, visit);
493
+ walkEach(doc, animation, steps, plant, reset, RAW_MODE, RAW_SHAPE, visit);
365
494
  }
366
495
 
367
496
  /**
@@ -381,12 +510,81 @@ export function loopedTime(trackTime: number, duration: number): number {
381
510
  */
382
511
  export function walkIn(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode): RawPose[] {
383
512
  const poses: RawPose[] = [];
384
- walkEach(doc, animation, steps, plant, reset, mode, (pose) => poses.push(pose));
513
+ walkEach(doc, animation, steps, plant, reset, mode, RAW_SHAPE, (pose) => poses.push(pose));
385
514
  return poses;
386
515
  }
387
516
 
388
- /** `walkIn`'s walk, each pose handed to `visit` in order as it is posed (`poseRawAnimationEach`). */
389
- function walkEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, visit: (pose: RawPose, index: number) => void): void {
517
+ /**
518
+ * `walkIn` as the scan reads it (`ScanPose`, issue #1179, the second part):
519
+ * the same walk — every pose posed by the same operations in the same order,
520
+ * every refusal at the same place — each pose assembled into what A10 reads.
521
+ * `./walk.ts`'s `scanLoopingWalk` is its one caller.
522
+ */
523
+ export function scanWalkIn(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, scanPlant: ScanPlant = {}): ScanPose[] {
524
+ const poses: ScanPose[] = [];
525
+ walkEach(doc, animation, steps, plant, reset, mode, scanPlant.vertices === undefined ? SCAN_SHAPE : scanShape(scanPlant), (pose) => poses.push(pose));
526
+ return poses;
527
+ }
528
+
529
+ /** What one step of a walk posed, before a pose is assembled from it (`PoseShape`). */
530
+ interface WalkStep {
531
+ trackTime: number;
532
+ animationTime: number;
533
+ world: Map<string, CoreWorld>;
534
+ slots: CoreSlotRow[];
535
+ drawOrder: string[];
536
+ shown: ShownGeometry[];
537
+ /** The events the step fired, computed when called — after the drawn rows, as the raw entry has always ordered its refusals. */
538
+ events: () => RawEvent[];
539
+ }
540
+
541
+ /**
542
+ * What a walk assembles each pose into (issue #1179, the second part): the raw
543
+ * entry's whole pose (`RAW_SHAPE`), or the scan's (`SCAN_SHAPE`). The walk
544
+ * (`walkEach`) is one body: every number a pose carries is computed there and
545
+ * in the two assemblers below, by the same calls, whatever the shape. A shape
546
+ * chooses only what is assembled from what was posed — the scan drops the
547
+ * readings, rows and copies nothing it reads is computed from — and keeps the
548
+ * order of every call that can refuse.
549
+ */
550
+ interface PoseShape<P> {
551
+ /** Whether a step forms the oracle's bone rows beside its world (`posedBoneWorld`), which no walk pose carries; `false` poses the world alone (`posedBoneWorldAlone`). */
552
+ rows: boolean;
553
+ /** The reset pose at the setup (`reset: 'setup'`). */
554
+ setup: (doc: CompiledDocument, world: Map<string, CoreWorld>, slots: CoreSlotRow[], drawOrder: string[], shown: ShownGeometry[], plant: CorePlant, mode: WalkMode) => P;
555
+ /** One step's pose. */
556
+ step: (doc: CompiledDocument, posed: WalkStep, plant: CorePlant, mode: WalkMode) => P;
557
+ }
558
+
559
+ /** The raw entry's pose, assembled as it always was: at the reset the bones, then the drawn rows; at a step the drawn rows, the events, then the bones. */
560
+ const RAW_SHAPE: PoseShape<RawPose> = {
561
+ rows: true,
562
+ setup: (doc, world, slots, drawOrder, shown, plant, mode) => ({ trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...rawDrawn(doc, shown, world, drawOrder, plant, mode), events: [], shown: drawOrderOf(shown, drawOrder) }),
563
+ step: (doc, p, plant, mode) => {
564
+ const drawn = rawDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode);
565
+ const events = p.events();
566
+ return { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
567
+ },
568
+ };
569
+
570
+ /** The scan's pose (`ScanPose`), in the raw shape's order: the scan's walk is a looping one, which fires no event. */
571
+ function scanShape(scanPlant: ScanPlant): PoseShape<ScanPose> {
572
+ return {
573
+ rows: false,
574
+ setup: (doc, world, slots, drawOrder, shown, plant, mode) => {
575
+ const bones = scanBones(doc, world);
576
+ return { animationTime: 0, bones, slots, drawn: scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant) };
577
+ },
578
+ step: (doc, p, plant, mode) => {
579
+ const drawn = scanDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode, scanPlant);
580
+ return { animationTime: p.animationTime, bones: scanBones(doc, p.world), slots: p.slots, drawn };
581
+ },
582
+ };
583
+ }
584
+ const SCAN_SHAPE: PoseShape<ScanPose> = scanShape({});
585
+
586
+ /** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`). */
587
+ function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
390
588
  const anim = doc.animations.find((a) => a.name === animation);
391
589
  if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
392
590
  const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
@@ -408,7 +606,7 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
408
606
  if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
409
607
  if (setup.world === null || setup.shown === null || setup.setup.slots === null || setup.setup.drawOrder === null) throw new CoreInputError('the raw walk\'s reset pose was not posed');
410
608
  ctx.phase = 'update';
411
- visit({ trackTime: 0, animationTime: 0, bones: rawBones(doc, setup.world), slots: setup.setup.slots, drawOrder: setup.setup.drawOrder, ...rawDrawn(doc, setup.shown, setup.world, setup.setup.drawOrder, plant, mode), events: [], shown: drawOrderOf(setup.shown, setup.setup.drawOrder) }, visited++);
609
+ visit(shape.setup(doc, setup.world, setup.setup.slots, setup.setup.drawOrder, setup.shown, plant, mode), visited++);
412
610
  }
413
611
  for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
414
612
  const dt = i === 0 ? 0 : steps[i - 1];
@@ -417,7 +615,7 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
417
615
  const before = ctx.time;
418
616
  ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (i === 0 ? 0 : Math.max(last, 0)));
419
617
  const sliders: SliderApplication[] = [];
420
- const posed = posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
618
+ const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
421
619
  if (i === 0) ctx.phase = 'update';
422
620
  const placeholders = new Map<string, string | null>();
423
621
  const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
@@ -430,12 +628,13 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
430
628
  if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
431
629
  const rank = new Map(drawOrder.map((n, r) => [n, r]));
432
630
  const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
433
- const drawn = rawDrawn(doc, shown, posed.world, drawOrder, plant, mode);
434
631
  // A looping walk fires no event here: what fires across the wrap was not measured, and the walk's one reader (A10) reads none. Nor does it cut its
435
632
  // drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
436
- const events = mode.loop ? [] : (plant.events ?? eventsFired)(anim.timelines.events, last, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
633
+ const from = last;
634
+ const events = (): RawEvent[] => (mode.loop ? [] : (plant.events ?? eventsFired)(anim.timelines.events, from, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]));
635
+ const pose = shape.step(doc, { trackTime, animationTime: t, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
437
636
  last = t;
438
- visit({ trackTime, animationTime: t, bones: rawBones(doc, posed.world), slots: slots.rows, drawOrder, ...drawn, events, shown }, visited++);
637
+ visit(pose, visited++);
439
638
  }
440
639
  }
441
640
 
package/src/core/walk.ts CHANGED
@@ -76,7 +76,7 @@
76
76
  * poses the class as the raw entry does, and refuses nothing the raw entry
77
77
  * does not.
78
78
  */
79
- import { setupPoseIn, walkIn, type RawBone, type RawDrawn, type WalkMode } from './raw.ts';
79
+ import { scanSetupIn, scanWalkIn, setupPoseIn, walkIn, type RawBone, type RawDrawn, type ScanPlant, type ScanPose, type WalkMode } from './raw.ts';
80
80
  import { activeBones, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
81
81
  import { historyTaint } from './constraints.ts';
82
82
  import type { TimelinePlant } from './animation.ts';
@@ -175,3 +175,23 @@ export function poseLoopingWalk(doc: CompiledDocument, animation: string, steps:
175
175
  const mode: WalkMode = { ...LOOPING, ...(walkPlant.time === undefined ? {} : { time: walkPlant.time }), ...(walkPlant.clock === undefined ? {} : { clock: walkPlant.clock }), ...(walkPlant.wrapResets === undefined ? {} : { wrapResets: walkPlant.wrapResets }) };
176
176
  return walkIn(doc, animation, steps, plant, 'setup', mode).map((p) => narrow(p, walkPlant.number));
177
177
  }
178
+
179
+ /**
180
+ * The scan's walk (issue #1179, the second part): `poseLoopingWalk` and
181
+ * `poseWalkSetup` as A10 reads them, each pose a `ScanPose` — the bones' world
182
+ * matrix and origin, the slot rows and the drawn attachments, and nothing the
183
+ * walk forms beside them for other readers (the bones' oracle rows and getter
184
+ * readings). The same walk: `walkIn`'s body in `./raw.ts`, posing each step by
185
+ * the same operations in the same order and refusing at the same places, with
186
+ * another assembler (`PoseShape`). `src/assertions/model/stepped_poses.ts` is
187
+ * its one caller; the public entries above are unchanged, and the core suite
188
+ * holds the two to the same numbers (`CO43`, `CO44`, `CO45`).
189
+ */
190
+ export function scanLoopingWalk(doc: CompiledDocument, animation: string, steps: readonly number[], scanPlant: ScanPlant = {}): ScanPose[] {
191
+ return scanWalkIn(doc, animation, steps, {}, 'setup', LOOPING, scanPlant);
192
+ }
193
+
194
+ /** `poseWalkSetup` as the scan reads it (`scanLoopingWalk`). */
195
+ export function scanWalkSetup(doc: CompiledDocument): ScanPose {
196
+ return scanSetupIn(doc, {}, LOOPING);
197
+ }
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