spine-rigc 2.0.0 → 2.0.2

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
@@ -43,8 +43,9 @@ installed.
43
43
  | a picture of a key pose | `rigc pose` | where each loose part PNG sits in it, in spec coordinates — the movement between two poses is then yours to key ([docs/MOTION.md](docs/MOTION.md)) |
44
44
  | the same picture, and a rig | `rigc chainfit` | the parts `pose` refuses because something is drawn over them — read through the candidate's own draw order and hierarchy, with the share of each part the answer was measured on |
45
45
 
46
- Everything in that table needs Bun and this package: no clone, no reference art, no
47
- art pipeline, no server.
46
+ Everything in that table needs Bun and this package — `preview` and `vote` also
47
+ `@esotericsoftware/spine-core` installed beside it (see below) — and no clone, no
48
+ reference art, no art pipeline, no server.
48
49
 
49
50
  ## What rigc is, and what it is not
50
51
 
@@ -147,8 +148,12 @@ Bun script, and npm only writes the shim that calls it.
147
148
 
148
149
  Installed, the command is `rigc`. The examples below spell it `bun cli.ts`
149
150
  because they are written from a clone of this repository (`bun install`, then run
150
- the CLI in place); the two are interchangeable — `rigc build …` is
151
- `bun cli.ts build …`.
151
+ the CLI in place). With `@esotericsoftware/spine-core` installed beside the package
152
+ the two are interchangeable — `rigc build …` is `bun cli.ts build …`. Without it,
153
+ `rigc` runs the entry that links none of the runtime (`bun cli_core.ts` in a clone):
154
+ `build` writes the same files gated without the parse, and a command that needs the
155
+ runtime is refused by name, saying how to get it (that entry's `--help` lists the
156
+ commands it runs). `rigc --version` names the entry that ran.
152
157
 
153
158
  One command is a repository workflow rather than a package one: `bench` measures
154
159
  against Spine's official example projects — fetched, never committed — and against
@@ -663,8 +668,11 @@ bun cli.ts build --rig … --motion … --out build/ --copy-images
663
668
  bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Contents/MacOS/Spine
664
669
  ```
665
670
 
666
- It prints the import and export exit codes, the validator's verdict on the
667
- export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
671
+ Of the editor's `--version` it records one line, the `Spine <x.y.z> …` line that
672
+ carries the version, and nothing else that command prints — the rest of it
673
+ names the licence holder — or `editor version: not found in --version output`
674
+ where no such line is there. It prints the import and export exit codes, the
675
+ validator's verdict on the export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
668
676
  animation **for each skin the build and the export both declare** — one
669
677
  render-and-check block per skin, with a per-skin roll-up under them, because a
670
678
  rig's contested art lives in its named skins and a single un-skinned check draws
package/cli_core.ts CHANGED
@@ -9,18 +9,29 @@
9
9
  *
10
10
  * Which commands those are is not listed here: it is every command whose
11
11
  * `runtime` is `false` in the one command table (`src/cli/shared.ts`), and
12
+ * every one whose `runtime` states a second body (`runtime.core`, below), and
12
13
  * `--help` prints exactly that set. This file imports nothing that links the
13
14
  * runtime — no module it reaches, statically, imports
14
15
  * `@esotericsoftware/spine-core` — so it runs with the package absent
15
16
  * (`RC24`/`RC25` in `selftest.ts` build that state and hold it). Each command
16
17
  * prints and writes what `cli.ts` prints and writes for it.
17
18
  *
19
+ * 📦 It is also what an install runs (issue #1061): `bin/rigc.cjs` hands `rigc`
20
+ * to this file wherever `@esotericsoftware/spine-core` does not resolve from the
21
+ * package — a devDependency, so every install until somebody adds it beside —
22
+ * and to `cli.ts` wherever it does. So the texts this entry prints are read in a
23
+ * clone and in an install alike, and where one names the entry that links the
24
+ * runtime it names both routes to it (issue #1072): installed, the same `rigc`
25
+ * once the runtime is installed beside the package; from a source checkout,
26
+ * `bun cli.ts`.
27
+ *
18
28
  * ⚠️ What it cannot do, and refuses by name rather than reaching for: an input
19
29
  * only the runtime reads — a Spine export, `--poser spine`, a fallback the
20
30
  * poser line names, the deform survey of a build the core refuses — and a
21
31
  * command whose body is the runtime's (`validate`, `bench`, `bonedist`,
22
32
  * `preview`, `vote`). Both say what they needed spine-core for and that this
23
- * entry links none of it; `cli.ts` runs them.
33
+ * entry links none of it; the command's refusal also names the two routes to
34
+ * the entry that runs it.
24
35
  *
25
36
  * `build` is the one name with a body here of its own (issue #1060,
26
37
  * `CORE_ENTRY_RUNS`): `cli.ts build`'s body (`runBuild`), writing the same
package/docs/AUTHORING.md CHANGED
@@ -139,9 +139,17 @@ green.** A red
139
139
  run leaves nothing on disk, so there is no half-written artifact to mistake for a
140
140
  result. There is no `--no-validate`, and there will not be one.
141
141
 
142
- `bun cli_core.ts build` — the entry that links none of spine-core — takes the same
143
- flags and writes the same files, byte for byte: `skeleton.json`, `skeleton.atlas`,
144
- `skeleton.model.json`, and the pages `--pack` or `--copy-images` put in `--out`.
142
+ The commands in this guide are spelled as a clone of the repository runs them,
143
+ `bun cli.ts …`. Installed, the command is `rigc`, and which entry it runs turns on one
144
+ thing: with `@esotericsoftware/spine-core` installed beside the package, `rigc` is
145
+ `bun cli.ts` and runs every command; without it, `rigc` runs `cli_core.ts` — the
146
+ entry that links none of spine-core, `bun cli_core.ts` in a clone — which refuses by
147
+ name the commands that need the runtime (its `--help` lists the ones it runs).
148
+ `rigc --version` names the entry that ran.
149
+
150
+ That entry's `build` takes the same flags and writes the same files, byte for byte:
151
+ `skeleton.json`, `skeleton.atlas`, `skeleton.model.json`, and the pages `--pack` or
152
+ `--copy-images` put in `--out`.
145
153
  Only the gate differs, because the round trip is spine-core's: it runs the model
146
154
  side over the document, and the round trip's own rules restated over the text the
147
155
  emitter wrote (A01, A02, A05, A07, A16, A31, A35, and A18 over a second compile's
@@ -222,7 +230,7 @@ What the flags mean:
222
230
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
223
231
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
224
232
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
225
- | `--poser` | `render`, `check` and `explain`: `core` or `spine` — on `render` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`). On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1) |
233
+ | `--poser` | `render`, `check` and `explain`: `core` or `spine` — on `render` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
226
234
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
227
235
  | `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
228
236
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -658,7 +666,9 @@ and a file's name is not evidence: a production pack shipped WebP pages called
658
666
  there — a WebP, JPEG, GIF, KTX or KTX2 file by its signature, or no format rigc
659
667
  recognises, with the first bytes in hex either way — and a file that begins as a
660
668
  PNG and runs out before its `IEND` chunk is called **truncated**, wherever it was
661
- cut. `build --atlas-in` and `explain --atlas-in` say so where they open the pack,
669
+ cut. A PNG whose `IHDR` states a width or a height of **0** is refused the same way
670
+ — the format requires each to be at least 1, and such a file describes no texel —
671
+ naming the size it states and which dimension is 0. `build --atlas-in` and `explain --atlas-in` say so where they open the pack,
662
672
  every such page in one sentence and before the compile; a loose part that is not a
663
673
  PNG is refused as the image it is; and `rigc validate` on a directory holding such
664
674
  a page fails `A06` with the same sentence:
@@ -671,6 +681,15 @@ a page fails `A06` with the same sentence:
671
681
  # measured. Re-export it as PNG
672
682
  ```
673
683
 
684
+ A page whose header and chunks read and whose **image data cannot be decoded** — a
685
+ compressed stream broken behind an intact chunk walk, or one that inflates short of
686
+ the rows its header states — fails `A06` too, under both profiles, with the
687
+ decoder's own words: `… is a PNG whose header and chunks read and whose image data
688
+ cannot be decoded: the decoder stops with "incorrect data check", so no texel of it
689
+ can be read`. The same sentence is what `render` and a mesh part's trace refuse such
690
+ a file with. Without it the default profile built such a page green — no rule it
691
+ runs decoded a texel — and wrote a directory `render` then could not draw.
692
+
674
693
  ⇒ **Re-export the page as PNG under the name the atlas gives it**, and the next run
675
694
  measures it like any other. ⚠️ `A06` deliberately does not read the width and
676
695
  height a WebP header carries in a fixed field. Doing so would print a size that no
@@ -1285,6 +1304,50 @@ So what is left refused is short, and each row is a pair two readings of the
1285
1304
  numbered series that crosses 9 → 10 is keyed **1, 2, … 9, 10, 11, 12**, which is
1286
1305
  what the editor does with it — and is not what a codepoint sort does.
1287
1306
 
1307
+ 🔢 **One kind of name the file cannot key in the editor's order: a plain
1308
+ non-negative integer.** `animations` is a JSON object, and an object lists every
1309
+ key that is an array index (`5`, `10` — not `01`, `05`, `-a` or `2b`) before the
1310
+ rest, in ascending order, however it was filled. So for those names the file's
1311
+ order is the editor's with the array-index names moved to the front, and the
1312
+ editor's re-sort on import is not a no-op. That repoints a `slider` exactly when
1313
+ its animation is one of the names that move index, and **that slider is a compile
1314
+ error** naming the slider, both indices and the animation it would come back
1315
+ applying (§5.1). A name list with an array-index name in it and no slider on a
1316
+ moved name builds: the editor re-keys the object by name and nothing else in it
1317
+ refers by position.
1318
+
1319
+ 🔁 **Measured 2026-10-02 (issue #1040) — a dated record.** `gallery/look` with its
1320
+ animations named `5, -a, 10, 2b, 01` (from `turn, tilt, sweep` and copies of
1321
+ `sweep` and `tilt`), its sliders `yaw -> "5"` and `tilt -> "-a"`, five bones
1322
+ renamed to the same five names and five events declared under them, built by
1323
+ rigc 2.0.0 with `--copy-images` and round-tripped by `tools/editor_roundtrip.ts`
1324
+ through a licensed editor writing data version 4.3.26. Read off the TEXT of each
1325
+ file (a JSON parser lists array-index keys first and would hide the answer):
1326
+
1327
+ | | the build | the export |
1328
+ | --- | --- | --- |
1329
+ | `animations` | `5, 10, -a, 01, 2b` | **`-a, 01, 2b, 5, 10`** — the comparator's order |
1330
+ | slider `yaw` | `"5"` (index 0) | **`"-a"`** — index 0 of the sorted list |
1331
+ | slider `tilt` | `"-a"` (index 2) | **`"2b"`** — index 2 of the sorted list |
1332
+ | `events` | `5, 10, -a, 2b, 01` | `-a, 01, 2b, 5, 10`; every event key still names its event |
1333
+ | `bones` (an array) | unchanged | unchanged |
1334
+ | an animation's `bones` group | the model's order, array-index names first | sorted by name — by name on both sides, so nothing moves |
1335
+
1336
+ Both sliders moved, each to the name at its old index — the mechanism #535 found
1337
+ with `tilt` as its unmoved control. The gate on the export failed only
1338
+ `A23_PHYSICS_CONSTRAINT_EFFECTIVE`, the editor's known loss of a `rotate`
1339
+ physics constraint ([FACE.md](FACE.md)), and `diff` moved only
1340
+ `animations.curve_kinds` (218 of 222): **neither the gate nor `diff` names the
1341
+ repointed sliders**, and the re-rendered mean absolute error of 8.43–10.41 is the
1342
+ size #535 measured out of order. Hence a refusal rather than a note.
1343
+
1344
+ ⇒ **`diff` names it now** (issue #1078). `constraints.refs` compares every name a
1345
+ constraint resolves — its bones and slots, a slider's `animation` and `property`,
1346
+ and the `from`/`to` pairs of a transform's `properties` — and its note names each
1347
+ constraint wired differently with both sides' names. On the probe above, planted
1348
+ without the editor: `0.333 refs 1/3 … — 2 wired differently: slider constraint
1349
+ "yaw": animation "-a" vs "5"; slider constraint "tilt": animation "2b" vs "-a"`.
1350
+
1288
1351
  **R11 — The `skins` array is written with `default` first and the rest in exactly
1289
1352
  the order R10 describes.** A skin is a **name** in the JSON half of the format and
1290
1353
  an **ordinal** in the binary half — `skins[readInt()]` for an attachment timeline,
@@ -3043,6 +3106,12 @@ stored round trips — so the emit is the editor's own order, and only what thos
3043
3106
  round trips leave open is a compile error. **R10** has the rule and the three
3044
3107
  shapes to avoid.
3045
3108
 
3109
+ 🔢 The one order the emit cannot give is a plain non-negative integer's: a JSON
3110
+ object lists `5` and `10` before every other name, so a slider whose animation
3111
+ that moves off its editor index is refused at compile, naming the animation it
3112
+ would come back applying. Measured on a licensed 4.3.26 editor; R10 carries the
3113
+ dated record.
3114
+
3046
3115
  ✅ **What that order does not settle is a compile error, not a hazard.** Names a
3047
3116
  codepoint sort and a friendlier one disagree about — `Turn` / `turn`, `turn2` /
3048
3117
  `turn10` — are emitted in the order the editor returns for them: measured,
@@ -5670,7 +5739,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5670
5739
  | `vertex N binds bone index I, which is not in the bone list` | §3.4 — a `"boneIndexing": "raw"` run on a mesh, bounding box, clipping polygon or path names a bone by its place in the bone array, and I is no place in it: past the end, negative or not a whole number. Count the rig's `bones` from 0, or give the bindings by name as `weights`, which cannot miss this way. ⚠️ **Refused earlier, not newly** (issue #917): the run is decoded to bone names once, when the attachment is built, and this is where an index that names no bone stops. Before that a mesh compiled and `A20` named the index at the gate, a bounding box, clipping polygon or path with its `lengths` stated compiled and `A33` named it, and only a path whose `lengths` rigc measures refused here, in this sentence. `MV04` plants it on each of the four kinds |
5671
5740
  | `the raw weighted run has a bone count of C at index I` / `the raw weighted run is truncated — vertex N claims C bone(s) and the array ends first` / `the raw weighted run decodes to N vertices but there are M uv pairs` | §3.4 — a mesh's `"boneIndexing": "raw"` run is not `boneCount, (index, x, y, weight) × boneCount` per vertex, one vertex per uv pair. These are the polygon's three sentences (whose last clause there reads `but vertexCount is M`), now asked of a mesh as well. Fix the run, or give the bindings by name as `weights`. ⚠️ **Refused earlier, not newly** (issue #917): the mesh run is decoded to bone names when the mesh is built, and a run that does not decode cannot be. Before that it compiled and `A20` named it at the gate (`has weights for 5 vertices but 4 uv pairs`, `vertex 3 has weight NaN`). `MV04` plants each |
5672
5741
  | `image "X.png" is not on disk at …` | fix the name, or point `--images` at the right directory |
5673
- | `image "X.png": /…/X.png is a WebP image (…), not a PNG: its first 12 byte(s) are …` | the file is there and is not a PNG — the name ends in `.png` and the bytes decide. Re-export it as PNG; the same sentence says **truncated** for a PNG that runs out before its `IEND`, and then the repair is a whole copy (§0.2) |
5742
+ | `image "X.png": /…/X.png is a WebP image (…), not a PNG: its first 12 byte(s) are …` | the file is there and is not a PNG — the name ends in `.png` and the bytes decide. Re-export it as PNG; the same sentence says **truncated** for a PNG that runs out before its `IEND`, and then the repair is a whole copy, and names the dimension for a PNG whose `IHDR` states a width or height of 0 (§0.2) |
5674
5743
  | `a "contour" generator traces the part's own alpha, and "X.png" is lifted off a packed page at the coordinates the atlas states, which on this file are not where its texels are — so there is no silhouette here to trace, only another part of the page. page "p.png" declares …` | the page's PNG is not the size its atlas declares, and the rest of the message is `A06`'s sentence for it: re-declare the page with the `scale:` header it names, or re-export the page at its declared size (§0.2) |
5675
5744
  | `--atlas-in <pack>.atlas: N of its M page(s) cannot be read as PNG, and nothing was compiled against the pack — page "p.png": …` | the same, for every page of the pack at once, before the compile: re-export each named page as PNG under the name the atlas gives it (§0.2) |
5676
5745
  | `parts/iris_open.png is 96x64 but slot "iris" declares 96x60` | R5 — a manifest `states:` entry whose art is not the window the part declares. Re-export the PNG, or fix the part's `size`; a quad sized against art of another size is the silence `A06` exists for, and the window is what the quad is built from |
@@ -5739,6 +5808,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5739
5808
  | `rig constraint "X": rotateMode is "CHAINSCALE"; known: Tangent, Chain, ChainScale` | §3.5.1 — only the first letter's case is free; anything else resolves to `undefined` in the parser |
5740
5809
  | `<file>: slot "X" has blend "ADDITIVE"; known: normal, additive, multiply, screen (only the first letter's case is free — …)` | §3.3 — write the mode in lower case, or with only its first letter upper-cased. `SkeletonJson` reads `blend` through `Utils.enumValue`, which upper-cases the first character and looks the rest up exactly, so `ADDITIVE` loaded as no mode at all (`tools/pose_oracle.ts dump` reads it as `null`), with no error. rigc used to compare the name case-insensitively and build that file green |
5741
5810
  | `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
5811
+ | `rig constraint "X": applies animation "Y", which the Spine file lists at index I of its animations and the Spine editor sorts to index J — so an editor import repoints this slider to "Z" …` | **R10** — a JSON object lists every array-index name (`5`, `10`) first, the editor re-sorts the animations and keeps a slider's by index (measured, R10's dated record). Rename the array-index animation names the message lists — a prefix or a leading zero (`a5`, `05`) is enough |
5742
5812
  | `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
5743
5813
  | `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
5744
5814
  | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360]` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
@@ -5853,13 +5923,13 @@ Fix A00 and run it again.
5853
5923
 
5854
5924
  | Assertion | Profile | What tripped it, and where to fix it |
5855
5925
  | --- | --- | --- |
5856
- | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 49 that entry does not run; `bun cli.ts build` and `rigc validate` run it (`RC28`). |
5926
+ | `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 49 that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
5857
5927
  | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | both | a 4.1/4.2-shaped `ik`/`transform`/`path`/`physics`/`slider` array. rigc emits the 4.3 `constraints` array, so this normally means hand-edited JSON Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5858
5928
  | `A02_NO_BONE_TRANSFORM_KEY` | both | `bone "…" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"` — rename it `inherit` in the rig spec Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5859
5929
  | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height`. **SKIP** when the skeleton carries no region attachment |
5860
5930
  | `A04_MESH_TRIANGLES_AND_ENCODING` | both | authored mesh geometry: triangle count not a multiple of 3, an index out of range, or a `vertices` length that disagrees with `uvs` (the weighted/unweighted trap) **SKIP** when the skeleton carries no mesh attachment |
5861
5931
  | `A05_CURVE_ARRAY_LENGTH` | both | a raw `curve` with the wrong number of values, a non-finite number in one, or a curve on a timeline that cannot take one. Four numbers **per value channel**. **SKIP** when no animation carries a timeline at all. Timelines with no `curve` on any key still PASS: every timeline name is checked against the channel table whether or not a curve sits on one Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5862
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND` (§0.2). That clause is validity too: nothing in rigc can read such a page back. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
5932
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND`, or names the dimension when its `IHDR` states a width or height of **0**, which the format forbids (§0.2). **Or** the file reads as a PNG and its **image data cannot be decoded** — its compressed stream is inflated (not unfiltered) and the decoder refuses it, or it inflates short of the rows the header states — and the message names the file and carries the decoder's own words (`the decoder stops with "incorrect data check"`). That clause is validity too: nothing in rigc can read such a page back — and its failure is the **one** line that names such a page: `A19` SKIPs over it and points here rather than naming it again, the way `A17` alone names a missing file. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
5863
5933
  | `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file. Three sentences are about blank lines and name three different repairs: `line 1: the file begins with a blank line` (or `with N blank lines` for a run, one finding) is a blank before the first page name — delete it; `line N: the file ends with a blank line` (or `with N blank lines`, one finding) is a blank after the last page block's final line — delete it, since rigc ends the file on one newline; `line N: consecutive blank lines` is two blank lines side by side between the first page and the last — delete one. None of the three is `the last page block declares no region`, which means a page name and its fields with no region after them. rigc writes none of the three: under `--atlas-in` the pack's blank lines are put in rigc's shape on re-emission (§0.2), so any of them means a file rigc did not write, handed to `validate`. ⚠️ An atlas with **no page block at all** — no non-blank line — is not one of those: its subject is absent, so this reports **SKIP** naming the byte count it read, and so do the four rules below whose subject is a page ([#608](https://github.com/firejune/rigc/issues/608)). A rig whose skins need no art writes exactly that file (§3.4), and before #608 this row refused it with two findings naming a page block that was not there. What an empty atlas does **not** excuse is an attachment that wants a region out of it — that is `A08` Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5864
5934
  | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both | three things, and the message says which: an attachment whose `path` names **no region** of this atlas; a `path` carrying **stray whitespace**, printed quoted so you can see it; an **atlas region name** carrying stray whitespace (`A07` names that same line with its line number). The first two are read off the raw file **before** the loader is asked, so the miss is named here with the skin, the slot, the placeholder and the attachment's own name — the four things `AtlasAttachmentLoader`'s own `Region not found in atlas: <path> (attachment: <name>)` does not carry. There is no `spine-html` clause here — a placeholder is free to differ from the region its `path` names **SKIP** when no attachment names a region *and* the atlas declares none — both of its subjects at once |
5865
5935
  | `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration ≠ the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule §4.5 states at compile time — held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all — a static rig has no duration |
@@ -5872,7 +5942,7 @@ Fix A00 and run it again.
5872
5942
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5873
5943
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. This is the one rule that names a missing page file; `A06` and `A19` SKIP over the page they could not read and point here rather than naming it again. **SKIP** when the atlas declares no page — as it is for `A06`, `A19` and `A27`; see `A07` |
5874
5944
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed — in `skeleton.json`, `skeleton.atlas` or the model document `skeleton.model.json`, the one named (the document's first differing line with it). That is a compiler bug, not a spec bug — report it Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
5875
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it**, and the clause above does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so a scan would come back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappears**, the scan finding a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it**: one row per page naming its parts and pointing at `A06`, which names what the file is. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page carrying a part has no file on disk and nothing else here failed: that page's parts were not read, so the reason names the pages not read and how many of the pages carrying a part were (or says none was) and points at `A17`, which fails naming each missing file. A missing page holding only the base plate moves nothing here: this rule had no part on it to read |
5945
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it**, and the clause above does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so a scan would come back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappears**, the scan finding a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page carrying a part was not read and nothing else here failed: the reason names the pages not read and how many of the pages carrying a part were (or says none was), and for each page points at the one rule that names its file — `A17` when the file is **not on disk**, `A06` when it is on disk and **cannot be read as PNG** (not a PNG, truncated, an `IHDR` dimension of 0, or image data that does not decode; `A06` fails naming what the file is on every such page, under both profiles). Undecodable data is found where this rule decodes, so the header's fast negative above still refuses a part with no alpha channel and no `tRNS` without opening it — a verdict about the header, not about the pixels. So one unreadable file is one red line, never two: with another `A19` failure standing, the unread page is that rule's alone and this one says nothing about it. A page holding only the base plate moves nothing here, missing or unreadable: this rule had no part on it to read. |
5876
5946
  | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. On a rig spec's `"boneIndexing": "raw"` mesh an index out of range or a run that does not decode is refused at compile first (§5.1, issue #917), so here it names a skeleton rigc did not compile. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
5877
5947
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1. **SKIPs** by name on authored or linked geometry and on a `segments` mesh, whose outline is weighted by distance like the rest of it — none of them claims a pinned rim |
5878
5948
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
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": {
@@ -44,6 +44,13 @@ Codex, Gemini CLI and Antigravity read skills from `.agents/skills/` in the
44
44
  workspace and none of them reads `node_modules`; `rigc skills install` puts every
45
45
  skill the package ships there, and `rigc skills --help` says what it refuses.
46
46
 
47
+ The package links no Spine runtime. `validate`, `preview` and `vote` below run
48
+ through `@esotericsoftware/spine-core`: install it beside the package
49
+ (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs them, and
50
+ `build` runs the round trip as well. Without it they are refused by name, and
51
+ `build` writes the same files gated without the parse — `rigc --version` names the
52
+ entry that ran.
53
+
47
54
  ## The loop
48
55
 
49
56
  1. `rigc build --rig <spec> --motion <spec> --images <dir> --out <dir>` compiles,
@@ -10,13 +10,14 @@
10
10
  * and the argument for the SKIP guards it shares with A17 and A19 (#568, #608)
11
11
  * — stays in `validate()`.
12
12
  */
13
- import { existsSync } from 'node:fs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { resolve } from 'node:path';
15
15
  import type { Verdicts } from '../harness.ts';
16
16
  import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
17
17
  import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
18
18
  import { pageFootprint, pageGridSentence } from '../../atlas.ts';
19
19
  import { readPngHeader } from '../../png.ts';
20
+ import { imageDataProblem } from '../../../tools/plate.ts';
20
21
 
21
22
  export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
22
23
  if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
@@ -81,6 +82,14 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
81
82
  // of a page states; this rule prefixes the size the atlas declares, the
82
83
  // value the file would have had to carry.
83
84
  //
85
+ // 🔒 **And this is the one line that names it** (issue #1064). A file's
86
+ // readability is this rule's subject — it runs under both profiles and
87
+ // asks `pngProblem` first, so on every input where `A19` would find the
88
+ // same page unreadable, this FAIL is already printed. `A19` therefore
89
+ // SKIPs naming the page and pointing here when it failed nothing else,
90
+ // and says nothing about the page when it did: one unreadable file, one
91
+ // line, the way #1055 made one missing file A17's alone.
92
+ //
84
93
  // ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
85
94
  // header carries its dimensions in a fixed field, and reading them would
86
95
  // print a number no oracle in this tree has checked (rigc links no WebP
@@ -96,7 +105,32 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
96
105
  `size was not measured: ${header.problem}`,
97
106
  );
98
107
  }
99
- const info = header.info;
108
+ // 🔒 **And a page whose header reads and whose image data does not decode
109
+ // is this rule's too** (issue #1074), for the reason #1064 made the
110
+ // unreadable header its: this is the one reader of every page that runs
111
+ // under both profiles. Measured before this, on the tree's 19 recipes with
112
+ // the first page's compressed stream overwritten past its first half,
113
+ // this rule PASSED on 19 of 19 (it read the header only) and `A19` alone
114
+ // named the file, as `threw: cannot decode PNG …`, on the 12 whose first
115
+ // page carries a part. On the 7 gallery rigs, whose first page is the base
116
+ // plate `A19` does not scan, nothing did: both rules PASSED under both
117
+ // profiles, and the default build wrote a directory `render` then died on.
118
+ //
119
+ // ⚠️ The stream is inflated, not decoded: `imageDataProblem` is the
120
+ // decoder's own refusing half and nothing after it — 68.9 ms over the 19
121
+ // builds' pages against `validate()`'s 286.2 ms under `spine`, where a
122
+ // whole decode would be 459.6 ms. A page whose data does not decode has no
123
+ // size worth comparing, so the grid clause is not asked of it: one file,
124
+ // one line.
125
+ const undecodable = header.info === null ? null : imageDataProblem(readFileSync(abs), abs);
126
+ if (undecodable !== null) {
127
+ fail(
128
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
129
+ `page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so ` +
130
+ `nothing on it was measured: ${undecodable}`,
131
+ );
132
+ }
133
+ const info = undecodable === null ? header.info : null;
100
134
  const onPage = atlas.regions.filter((region) => region.page.name === page.name);
101
135
  const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
102
136
  if (gridSaid !== null) {
@@ -17,8 +17,8 @@ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
17
17
  import type { StageFacts } from '../facts/stage.ts';
18
18
  import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
19
19
  import { pageFootprint } from '../../atlas.ts';
20
- import { colourTypeName, readPngHeader } from '../../png.ts';
21
- import { readPlate } from '../../../tools/plate.ts';
20
+ import { colourTypeName, NotAPngError, readPngHeader } from '../../png.ts';
21
+ import { type Plate, readPlate } from '../../../tools/plate.ts';
22
22
  import type { RigInfo } from '../../types.ts';
23
23
 
24
24
  export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
@@ -122,6 +122,7 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
122
122
  // moves nothing here (the size it was not measured at is `A06`'s to say).
123
123
  let pagesWithParts = 0;
124
124
  const unread: string[] = [];
125
+ const unreadable: string[] = [];
125
126
  for (const page of atlas.pages) {
126
127
  const abs = resolve(input.atlasDir, page.name);
127
128
  const on = sharedPages.get(page.name) ?? [];
@@ -131,30 +132,60 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
131
132
  if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
132
133
  continue;
133
134
  }
134
- // 🔒 **A page that is not a PNG is a non-measurement for every part on
135
- // it** (issue #732), stated the way #705's and #715's are: a FAIL naming
136
- // what was not read, because `skip()` is per assertion and would delete
137
- // the verdicts on every other page of the same report. The file's
138
- // identity is `A06`'s to judge, and this sentence points there. A page
139
- // holding nothing but the full-stage base plate has no part to judge and
140
- // says nothing, exactly as a readable one would.
135
+ // 🔒 **A page that is on disk and cannot be read as a PNG is named once,
136
+ // by `A06`, and this body only says it did not read it** (issue #1064,
137
+ // the other half of #1055's rule). Until this it FAILed here with a
138
+ // second sentence about the same file (#732's `not measured … belongs to
139
+ // A06`), so one unreadable page was two red lines: measured on the tree's
140
+ // 19 recipes with the first page replaced by text bytes, by a PNG cut
141
+ // inside its IDAT and by one with a wrong signature byte, A06 FAILed
142
+ // naming the file on 19 of 19 under both profiles and this rule added its
143
+ // FAIL on the 12 whose first page carries a part (`spine-html`).
144
+ //
145
+ // ⭐ Who names it is read off the two bodies, not chosen. Both ask
146
+ // `readPngHeader` (`pngProblem`, the one reader of a page's identity) the
147
+ // same question about the same file, A06 runs under both profiles and this
148
+ // rule under `spine-html` alone, and A06 FAILs on every page for which it
149
+ // answers with a problem — so on every input that reaches this branch, A06
150
+ // has already named the file and the report is red. A FAIL here would be
151
+ // the second naming and a PASS a certificate over alpha nobody read, so
152
+ // the page joins `#1055`'s unread list, with its own owner beside it.
153
+ //
154
+ // ⚠️ #732 chose a FAIL because `skip()` is per assertion and would delete
155
+ // the verdicts on every other page. Under #1055's rule that argument no
156
+ // longer forces it: the SKIP below is printed only when this body failed
157
+ // nothing else, so the verdicts it would replace are all passes, and its
158
+ // reason says how many pages they covered. With another failure standing,
159
+ // the failures stand and the unreadable page is A06's alone. A page
160
+ // holding nothing but the base plate had nothing for this rule to read
161
+ // either way and moves nothing, exactly as a missing one does.
162
+ //
163
+ // 🔒 **So does a page whose header reads and whose image data does not
164
+ // decode** (issue #1074). It used to reach `readPlate` below and come out
165
+ // as this rule's `threw: cannot decode PNG …` — a stack message as the
166
+ // detail, on 12 of the 19 recipes with a stream broken — because `A06`
167
+ // read the header only. `A06` now asks the decoder's refusing half too
168
+ // (`imageDataProblem`) and fails naming the file under both profiles, so
169
+ // the page is its, by #1064's rule, and joins the same list — at the two
170
+ // places below where this rule decodes (`decoded`), and not before them:
171
+ // the header's fast negative (#215) still refuses a file that has nowhere
172
+ // to keep a clear texel without opening it, which is a verdict about the
173
+ // header and not about the pixels A06 found unreadable (PT13).
141
174
  const header = readPngHeader(abs);
142
- if (header.problem !== null) {
143
- const shared = on.length > 1;
144
- const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
145
- if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
146
- const subject = shared
147
- ? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
148
- : `part image "${page.name}" is`;
149
- fail(
150
- 'A19_OVERLAY_PNGS_HAVE_ALPHA',
151
- `${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
152
- 'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
153
- "belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
154
- '--profile spine-html: the default --profile spine does not run this check.',
155
- );
175
+ if (header.info === null) {
176
+ if (carriesPart) unreadable.push(page.name); // A06 names the file; this body says only that it did not read it
156
177
  continue;
157
178
  }
179
+ const decoded = (): Plate | null => {
180
+ try {
181
+ return readPlate(abs);
182
+ } catch (err) {
183
+ // `pngProblem` has accepted the file, so this is the decoder's refusal (`readPlate`'s, #1074): A06 names it.
184
+ if (!(err instanceof NotAPngError)) throw err;
185
+ if (carriesPart) unreadable.push(page.name);
186
+ return null;
187
+ }
188
+ };
158
189
  if (on.length > 1) {
159
190
  // 🚨 A rotated region is refused by A06 under this profile, so this
160
191
  // reading was assumed to be cosmetic — a rectangle printed beside a
@@ -188,7 +219,8 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
188
219
  // what was not measured, which is also what "green means measured"
189
220
  // requires: a part this rule could not read must not be certified by
190
221
  // it.
191
- const plate = readPlate(abs);
222
+ const plate = decoded();
223
+ if (plate === null) continue;
192
224
  // 🚨 **The scan's coordinates are the atlas's, so a file that is not the
193
225
  // declared grid is a non-measurement for every region on it** (issue
194
226
  // #715), and #705's clause above does not cover it. That one fires when
@@ -300,7 +332,8 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
300
332
  // loose page the file IS the part, so no atlas coordinate is read and
301
333
  // #705's and #715's non-measurements cannot arise here — and the scan
302
334
  // stops at the first clear texel, as the shared page's does.
303
- const plate = readPlate(abs);
335
+ const plate = decoded();
336
+ if (plate === null) continue;
304
337
  let transparent = false;
305
338
  for (let y = 0; y < plate.height && !transparent; y++) {
306
339
  for (let x = 0; x < plate.width; x++) {
@@ -323,15 +356,49 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
323
356
  'spine does not run this check.',
324
357
  );
325
358
  }
326
- if (failures > 0 || unread.length === 0) return;
327
- skip(
328
- 'A19_OVERLAY_PNGS_HAVE_ALPHA',
329
- unread.length === pagesWithParts
330
- ? `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
331
- 'A17_ATLAS_PAGE_FILES_EXIST names file by file, and that is not a pass while every part is unread'
332
- : `${unread.length} of the ${pagesWithParts} page(s) carrying a part were not read — ` +
333
- `${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
334
- `A17_ATLAS_PAGE_FILES_EXIST names. Every part on the ${pagesWithParts - unread.length} page(s) that were ` +
335
- "read can draw a transparent pixel, and that is not a pass while a part's alpha is unread",
359
+ if (failures > 0 || unread.length + unreadable.length === 0) return;
360
+ skip('A19_OVERLAY_PNGS_HAVE_ALPHA', unreadAlpha(pagesWithParts, unread, unreadable));
361
+ }
362
+
363
+ /**
364
+ * The SKIP's reason over the pages carrying a part that this rule did not
365
+ * read: `missing` (not on disk, which A17 names — issue #1055) and
366
+ * `unreadable` (on disk and not a PNG rigc can read, which A06 names — issue
367
+ * #1064). With only missing pages it is #1055's sentence to the byte; each
368
+ * group names its own owner, so one file is pointed at the one rule that
369
+ * names it.
370
+ */
371
+ function unreadAlpha(pagesWithParts: number, missing: readonly string[], unreadable: readonly string[]): string {
372
+ const A17 = 'A17_ATLAS_PAGE_FILES_EXIST';
373
+ const A06 = 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG';
374
+ const names = (pages: readonly string[]): string => pages.map((name) => JSON.stringify(name)).join(', ');
375
+ const total = missing.length + unreadable.length;
376
+ if (total === pagesWithParts) {
377
+ if (unreadable.length === 0) {
378
+ return (
379
+ `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
380
+ `${A17} names file by file, and that is not a pass while every part is unread`
381
+ );
382
+ }
383
+ if (missing.length === 0) {
384
+ return (
385
+ `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part can be read as PNG, ` +
386
+ `which ${A06} names file by file, and that is not a pass while every part is unread`
387
+ );
388
+ }
389
+ return (
390
+ `no part's alpha was read: of the ${pagesWithParts} page file(s) carrying a part, ${missing.length} are not ` +
391
+ `on disk, which ${A17} names file by file, and ${unreadable.length} cannot be read as PNG, which ${A06} ` +
392
+ 'names file by file, and that is not a pass while every part is unread'
393
+ );
394
+ }
395
+ const because = [
396
+ ...(missing.length > 0 ? [`${names(missing)} — because the file is not on disk, which ${A17} names`] : []),
397
+ ...(unreadable.length > 0 ? [`${names(unreadable)} — because the file cannot be read as PNG, which ${A06} names`] : []),
398
+ ].join(', and — ');
399
+ return (
400
+ `${total} of the ${pagesWithParts} page(s) carrying a part were not read — ${because}. Every part on the ` +
401
+ `${pagesWithParts - total} page(s) that were read can draw a transparent pixel, and that is not a pass while ` +
402
+ "a part's alpha is unread"
336
403
  );
337
404
  }
@@ -55,7 +55,9 @@ interface EmittedTexts {
55
55
 
56
56
  /** A00's SKIP here: what did not run, and where it does. */
57
57
  export const SKIP_NO_ROUND_TRIP =
58
- "spine-core's parser is the subject of this rule and this entry links none of it — `bun cli.ts build` and `rigc validate` run the round trip, and the selftest and CI run it on every corpus build";
58
+ "spine-core's parser is the subject of this rule and this entry links none of it — the entry that links it runs the round trip on build and validate " +
59
+ '(installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts`), ' +
60
+ 'and the selftest and CI run it on every corpus build';
59
61
 
60
62
  /** The code this file SKIPs rather than restates. */
61
63
  const A00 = 'A00_ROUNDTRIP_PARSE';
@@ -1573,7 +1573,8 @@ const MODEL_AND_TEXT_GATE: BuildGate = {
1573
1573
  const notRun = text.skipped.filter((x) => x.assertion === 'A00_ROUNDTRIP_PARSE').map((x) => x.assertion);
1574
1574
  console.log(
1575
1575
  ` .. here: ${ran(model)} rule(s) on the model side over the document, ${ran(text) - notRun.length} of the round trip's own restated over the emitted text; ` +
1576
- `not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only \`bun cli.ts build\` and \`rigc validate\` run`,
1576
+ `not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only the entry that links spine-core runs, on build and validate: ` +
1577
+ `installed, the same \`rigc\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts\``,
1577
1578
  );
1578
1579
  return report.failures.length;
1579
1580
  },
package/src/cli/shared.ts CHANGED
@@ -1238,22 +1238,25 @@ export const COMMANDS: CommandDoc[] = [
1238
1238
  for: 'the gate round-trips every build through it before anything is written',
1239
1239
  core: {
1240
1240
  usage: [
1241
- 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files cli.ts build writes; gated without spine-core — see build --help)',
1241
+ 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
1242
1242
  `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
1243
1243
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1244
1244
  'rigc build --cut <name> --cuts <cuts.json>',
1245
1245
  ],
1246
1246
  notes: [
1247
- 'this entry\'s build writes what `bun cli.ts build` writes — skeleton.json, skeleton.atlas,',
1248
- 'skeleton.model.json and the pages --pack or --copy-images put in --out — by the same',
1249
- 'body, and only when no assertion fails. Its gate is not the round trip through',
1250
- 'spine-core, which this entry links none of. What runs instead: the model side over',
1251
- 'the document (every assertion moved off the round trip), and the round trip\'s own',
1252
- 'rules restated over the text the emitter wrote — A01, A02, A05, A07, A16, A31, A35,',
1253
- 'and A18 over a second, independent compile\'s skeleton, atlas and document. What does',
1254
- 'not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming spine-core,',
1255
- 'and `bun cli.ts build` and `rigc validate` run it. The report\'s last line says which',
1256
- 'ran here and which did not.',
1247
+ 'this entry\'s build writes what the entry that links spine-core writes — skeleton.json,',
1248
+ 'skeleton.atlas, skeleton.model.json and the pages --pack or --copy-images put in',
1249
+ '--out — by the same body, and only when no assertion fails. Its gate is not the round',
1250
+ 'trip through spine-core, which this entry links none of. What runs instead: the model',
1251
+ 'side over the document (every assertion moved off the round trip), and the round',
1252
+ 'trip\'s own rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,',
1253
+ 'A31, A35, and A18 over a second, independent compile\'s skeleton, atlas and document.',
1254
+ 'What does not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming',
1255
+ 'spine-core. The report\'s last line says which ran here and which did not. A00 runs',
1256
+ 'in build and validate on the entry that links spine-core: installed, the same `rigc`',
1257
+ 'runs that entry once @esotericsoftware/spine-core is installed beside the package',
1258
+ '(`rigc --version` names the entry that ran); from a source checkout, it is',
1259
+ '`bun cli.ts`.',
1257
1260
  ],
1258
1261
  },
1259
1262
  },
@@ -1737,7 +1740,8 @@ export function usageText(docs: readonly CommandDoc[], checkout: string, rules:
1737
1740
  '',
1738
1741
  checkout === 'cli.ts'
1739
1742
  ? '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)'
1740
- : `(from a source checkout: \`bun ${checkout} <command>\` runs the commands below, which link nothing of spine-core; \`bun cli.ts <command>\` runs every command)`,
1743
+ : `(installed: \`rigc\` runs this entry where @esotericsoftware/spine-core is not installed beside the package, and every command once it is; ` +
1744
+ `from a source checkout: \`bun ${checkout} <command>\` runs the commands below, which link nothing of spine-core; \`bun cli.ts <command>\` runs every command)`,
1741
1745
  '',
1742
1746
  'usage:',
1743
1747
  ...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
@@ -2253,7 +2257,8 @@ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[]): SpineRun
2253
2257
  const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
2254
2258
  return new SpineRuntimeError(
2255
2259
  `\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
2256
- `The commands this entry runs are ${runs.join(', ')}; \`bun cli.ts ${doc.name}\` runs it`,
2260
+ `The commands this entry runs are ${runs.join(', ')}; the entry that links spine-core runs \`${doc.name}\` — ` +
2261
+ `installed, \`rigc ${doc.name}\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts ${doc.name}\``,
2257
2262
  );
2258
2263
  }
2259
2264
 
package/src/compile.ts CHANGED
@@ -338,6 +338,54 @@ export function editorAnimationOrder(names: readonly string[]): string[] {
338
338
  return keyedOrder(editorNamesInOrder(names, 'animations'));
339
339
  }
340
340
 
341
+ /** Where a slider's animation stands in the file and after the editor's re-sort, when the two differ (`sliderAnimationTheEditorRepoints`). */
342
+ interface RepointedSlider {
343
+ /** The animation's index in the order the file keys (`editorAnimationOrder`). */
344
+ fileIndex: number;
345
+ /** Its index in the comparator's order, which the editor re-sorts the object into. */
346
+ editorIndex: number;
347
+ /** The animation the editor puts at `fileIndex` — the one the slider comes back naming. */
348
+ becomes: string;
349
+ /** The array-index names the file lists ahead of the comparator's order. */
350
+ indexNames: string[];
351
+ }
352
+
353
+ /**
354
+ * Whether an editor import moves a slider off `animation` — `null` when it does
355
+ * not (issue #1040).
356
+ *
357
+ * 🔁 **Measured, not inferred.** The editor holds a slider's animation by its
358
+ * INDEX in the text's order and re-sorts the `animations` object into its
359
+ * comparator's order (#535). Since #1034 the file's order is the comparator's
360
+ * with every array-index name (`5`, `10`) first, because a JSON object lists
361
+ * those keys first however it was filled. On 2026-10-02 a build keying
362
+ * `5, 10, -a, 01, 2b` went through Spine 4.3.26 and came back
363
+ * `-a, 01, 2b, 5, 10`, with `yaw -> "5"` returned as `yaw -> "-a"` (index 0
364
+ * before and after) and `tilt -> "-a"` as `tilt -> "2b"` (index 2). So the
365
+ * slider moves exactly when its animation's index differs between the two
366
+ * orders, and only then: an array-index name elsewhere in the list repoints
367
+ * nothing, and is not refused.
368
+ *
369
+ * A name set the comparator cannot order is R10's refusal, raised when the
370
+ * animations are emitted; this check has no order to read there and says
371
+ * nothing rather than raising it a second time in other words.
372
+ */
373
+ function sliderAnimationTheEditorRepoints(animation: string, names: readonly string[]): RepointedSlider | null {
374
+ let sorted: string[];
375
+ try {
376
+ sorted = editorNamesInOrder(names, 'animations');
377
+ } catch (err) {
378
+ if (err instanceof CompileError) return null;
379
+ throw err;
380
+ }
381
+ const file = keyedOrder(sorted);
382
+ const fileIndex = file.indexOf(animation);
383
+ if (sorted[fileIndex] === animation) return null;
384
+ // An array-index name is one an object lists ahead of a key it was given first — the language's rule, not a restatement of it.
385
+ const indexNames = file.filter((name) => keyedOrder(['\u0000', name])[0] === name);
386
+ return { fileIndex, editorIndex: sorted.indexOf(animation), becomes: sorted[fileIndex], indexNames };
387
+ }
388
+
341
389
  /**
342
390
  * The order the editor writes a list of skin or animation names in, refusing by
343
391
  * name every pair the five round trips leave open.
@@ -6785,6 +6833,21 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): M
6785
6833
  (known.length ? ` (it declares: ${known.join(', ')})` : ' (it declares none at all)'),
6786
6834
  );
6787
6835
  }
6836
+ // 🔁 The editor keeps a slider's animation by index and re-sorts the object
6837
+ // the file lists array-index names first in (issue #1040, measured).
6838
+ const repointed = sliderAnimationTheEditorRepoints(animation, [...ctx.animationNames]);
6839
+ if (repointed !== null) {
6840
+ const spelled = repointed.indexNames.map((name) => `"${name}"`).join(', ');
6841
+ throw new CompileError(
6842
+ `${where}: applies animation "${animation}", which the Spine file lists at index ${repointed.fileIndex} of ` +
6843
+ `its animations and the Spine editor sorts to index ${repointed.editorIndex} — so an editor import ` +
6844
+ `repoints this slider to "${repointed.becomes}", in a file that still parses and gates green. A JSON object ` +
6845
+ `lists every array-index key (${spelled} here) before the rest, the editor re-sorts the animations into its ` +
6846
+ 'own order on import, and a slider holds its animation by index (measured on Spine 4.3.26; AUTHORING R10). ' +
6847
+ `Rename ${spelled} so no animation name is a plain non-negative integer — a prefix or a leading zero ` +
6848
+ `("a${repointed.indexNames[0]}", "0${repointed.indexNames[0]}") puts the file's order and the editor's in step.`,
6849
+ );
6850
+ }
6788
6851
  out.animation = animation;
6789
6852
  for (const field of ['additive', 'loop'] as const) {
6790
6853
  if (spec[field] !== undefined) out[field] = spec[field];
package/src/diff.ts CHANGED
@@ -884,8 +884,15 @@ function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentF
884
884
 
885
885
  interface ConstraintFact {
886
886
  type: string;
887
- /** Every bone or slot the constraint names, sorted — its wiring. */
887
+ /**
888
+ * Every name the constraint resolves, as `<field>:<name>` and sorted — its
889
+ * wiring: the bones and slots it reads and writes, a slider's animation, and
890
+ * the properties a slider or a transform maps (`property:rotate`,
891
+ * `property:x>y`). See `constraintFacts` for which fields and why.
892
+ */
888
893
  refs: string;
894
+ /** The same tokens as a list, for the note that spells a disagreement out. */
895
+ refList: string[];
889
896
  }
890
897
 
891
898
  /**
@@ -898,6 +905,28 @@ interface ConstraintFact {
898
905
  * way, so the report read 1.000 while its own header line counted one constraint
899
906
  * more than `constraints.count` did, measured at 14/14 against 13/13 on the
900
907
  * export that made the card.
908
+ *
909
+ * ⭐ **What the wiring is** (issue #1078): every field of a 4.3 constraint whose
910
+ * value is a NAME the parser resolves — `SPEC_COVERAGE.md` §1.4, field by field.
911
+ * `bones`, `bone`, `target`, `source` and `slot` name a bone or a slot; a
912
+ * slider's `animation` names an animation; a slider's `property` and the
913
+ * `from`/`to` keys of a transform's `properties` name the property read and the
914
+ * property written, from a closed set the parser throws outside of. Every other
915
+ * field is a number, a flag or a mode — a value, which this file does not compare
916
+ * on either side of any split; the value walk (`diffSkeletonValues`) does.
917
+ *
918
+ * 🔍 Until #1078 the list stopped at the five bone and slot fields, so an editor
919
+ * import that repointed both sliders of a probe (`yaw -> "5"` came back as
920
+ * `yaw -> "-a"`, issue #1040) read 1.000 on every constraint measure. The two
921
+ * property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
922
+ * a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
923
+ * `x>y` moved no measure here and no value in the value walk either (its skip
924
+ * list names `properties`), so nothing in the tree compared them at all.
925
+ *
926
+ * ⚠️ Absent is no token, never a default: a transform with no `properties` maps
927
+ * nothing, and two sides that both omit a field agree about it. And the tokens
928
+ * are a set, so the order a file writes `properties` in — an object, re-keyed by
929
+ * any writer — is not a difference.
901
930
  */
902
931
  function constraintFacts(root: Json): Map<string, ConstraintFact> {
903
932
  const out = new Map<string, ConstraintFact>();
@@ -910,15 +939,91 @@ function constraintFacts(root: Json): Map<string, ConstraintFact> {
910
939
  const s = str(b);
911
940
  if (s !== null) refs.add(`bone:${s}`);
912
941
  }
913
- for (const key of ['bone', 'target', 'source', 'slot'] as const) {
942
+ for (const key of ['bone', 'target', 'source', 'slot', 'animation', 'property'] as const) {
914
943
  const s = str(con[key]);
915
944
  if (s !== null) refs.add(`${key}:${s}`);
916
945
  }
917
- out.set(at, { type: str(con.type) ?? '(none)', refs: [...refs].sort().join(' ') });
946
+ if (isObj(con.properties)) {
947
+ for (const [from, entry] of Object.entries(con.properties)) {
948
+ const to = isObj(entry) && isObj(entry.to) ? Object.keys(entry.to) : [];
949
+ // A `from` with no `to` maps nothing at runtime, but it is still a
950
+ // statement the file makes, so it is a token of its own rather than
951
+ // silently equal to the key being absent.
952
+ if (to.length === 0) refs.add(`property:${from}>`);
953
+ for (const t of to) refs.add(`property:${from}>${t}`);
954
+ }
955
+ }
956
+ const refList = [...refs].sort();
957
+ out.set(at, { type: str(con.type) ?? '(none)', refs: refList.join(' '), refList });
918
958
  }
919
959
  return out;
920
960
  }
921
961
 
962
+ /** How many disagreeing constraints `constraints.refs`' note spells out before it counts the rest. */
963
+ const REFS_SPELLED_OUT = 3;
964
+
965
+ /**
966
+ * One constraint's disagreement, by field: `slider constraint "yaw": animation
967
+ * "5" vs "-a"` — the candidate's names for each field that differs, then the
968
+ * reference's, `none` where a side has none.
969
+ */
970
+ function refsDisagreement(at: string, a: ConstraintFact, b: ConstraintFact): string {
971
+ const byField = (f: ConstraintFact): Map<string, string[]> => {
972
+ const m = new Map<string, string[]>();
973
+ for (const token of f.refList) {
974
+ const colon = token.indexOf(':');
975
+ const field = token.slice(0, colon);
976
+ m.set(field, [...(m.get(field) ?? []), token.slice(colon + 1)]);
977
+ }
978
+ return m;
979
+ };
980
+ const af = byField(a);
981
+ const bf = byField(b);
982
+ const fields = [...new Set([...af.keys(), ...bf.keys()])].sort();
983
+ const spell = (names: string[] | undefined): string =>
984
+ names === undefined ? 'none' : names.map((n) => JSON.stringify(n)).join(', ');
985
+ const parts = fields
986
+ .filter((f) => (af.get(f) ?? []).join(' ') !== (bf.get(f) ?? []).join(' '))
987
+ .map((f) => `${f} ${spell(af.get(f))} vs ${spell(bf.get(f))}`);
988
+ return `${at}: ${parts.join(', ')}`;
989
+ }
990
+
991
+ /**
992
+ * `constraints.refs`: `agreement` over the constraints, unchanged in what it
993
+ * counts, with a note naming each disagreement and both sides' names — the
994
+ * figure alone says that some constraint is wired differently, and a reader
995
+ * acting on it needs to know which one and to what (issue #1078).
996
+ *
997
+ * The note's two counts partition the misses: a constraint both sides hold whose
998
+ * wiring differs is spelled out, and one only one side holds is counted, since
999
+ * `constraints.names` is the measure that names it.
1000
+ */
1001
+ function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, ConstraintFact>): DiffMeasure {
1002
+ const base = agreement(
1003
+ 'constraints.refs',
1004
+ 'each constraint names the same bones, slots, animation and properties',
1005
+ a,
1006
+ b,
1007
+ (x, y) => x.refs === y.refs,
1008
+ );
1009
+ const differ: string[] = [];
1010
+ for (const [at, fact] of a) {
1011
+ const other = b.get(at);
1012
+ if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(at, fact, other));
1013
+ }
1014
+ const missed = base.total - base.matched;
1015
+ if (missed === 0) return base;
1016
+ const oneSided = missed - differ.length;
1017
+ const spelled = differ.slice(0, REFS_SPELLED_OUT);
1018
+ const note =
1019
+ (differ.length === 0 ? '' : `${differ.length} wired differently: ${spelled.join('; ')}`) +
1020
+ (differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
1021
+ (oneSided === 0
1022
+ ? ''
1023
+ : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which \`constraints.names\` names`);
1024
+ return { ...base, note };
1025
+ }
1026
+
922
1027
  function diffConstraints(c: Json, r: Json): DiffSection {
923
1028
  const a = constraintFacts(c);
924
1029
  const b = constraintFacts(r);
@@ -932,7 +1037,7 @@ function diffConstraints(c: Json, r: Json): DiffSection {
932
1037
  counted([...b.values()].map((f) => f.type)),
933
1038
  ),
934
1039
  agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
935
- agreement('constraints.refs', 'each constraint names the same bones and slots', a, b, (x, y) => x.refs === y.refs),
1040
+ constraintRefs(a, b),
936
1041
  ]);
937
1042
  }
938
1043
 
package/src/png.ts CHANGED
@@ -154,11 +154,14 @@ function whatItIs(buf: Uint8Array): string {
154
154
  * decode PNG …: unexpected end of file`, and reached `build --atlas-in` as a
155
155
  * stack trace — three sentences about one file, and none said what it was.
156
156
  *
157
- * It says three things and decodes nothing: what the file is instead (by the
157
+ * It says four things and decodes nothing: what the file is instead (by the
158
158
  * signature it does carry, with its first bytes in hex either way), whether a
159
- * file that begins as a PNG ends before its chunks do, and whether IHDR comes
160
- * first. The chunk walk reads only the eight-byte chunk headers, so it costs a
161
- * skip through the file and no inflate.
159
+ * file that begins as a PNG ends before its chunks do, whether IHDR comes
160
+ * first, and whether IHDR states a size of at least 1x1 (issue #1073). The
161
+ * chunk walk reads only the eight-byte chunk headers, so it costs a skip
162
+ * through the file and no inflate — which is also why it cannot say whether
163
+ * the image data decodes: that is `imageDataProblem` in
164
+ * [`tools/plate.ts`](../tools/plate.ts), beside the decoder (issue #1074).
162
165
  *
163
166
  * ⚠️ **"Truncated" is judged by the chunk walk, not by a length floor.** The
164
167
  * floor that stood here (`buf.length < 26`, "too short") never said truncated,
@@ -208,6 +211,28 @@ export function pngProblem(buf: Uint8Array, path: string): string | null {
208
211
  `its ${type} chunk at byte ${at} declares ${length} byte(s) of data, which with its CRC runs to byte ${next}`,
209
212
  );
210
213
  }
214
+ if (type === 'IHDR') {
215
+ // 🔒 **A dimension of 0 is a file that describes no texel** (issue
216
+ // #1073), and the format says so: IHDR's width and height are each at
217
+ // least 1. Accepted here, such a page was read as a valid PNG of the
218
+ // wrong size — A06 printed its grid sentence over a ratio of 0.0000, A19
219
+ // a "not measured" line once per region on the page (13 on one export),
220
+ // the compiler took the 0 as a loose part's size and was refused by A03
221
+ // and A10 (`non-positive size`, `x NaN`) or by the mesher (`bad part
222
+ // size 0x150`) without the file named, and `render` drew it in silence.
223
+ // Refused here, it is one fact about one file, said by the one reader
224
+ // every other reader asks first.
225
+ const width = view.getUint32(at + 8);
226
+ const height = view.getUint32(at + 12);
227
+ if (width === 0 || height === 0) {
228
+ const zero = width === 0 && height === 0 ? 'its width and its height are' : width === 0 ? 'its width is' : 'its height is';
229
+ return (
230
+ `${path} is a PNG whose IHDR chunk states its size as ${width}x${height}: ${zero} 0, where the format ` +
231
+ 'requires each dimension to be at least 1, so the file describes no texel and nothing in it can be ' +
232
+ 'measured or drawn. Re-export it'
233
+ );
234
+ }
235
+ }
211
236
  if (type === 'IEND') return null;
212
237
  previous = `its ${type} chunk at byte ${at}`;
213
238
  at = next;
@@ -216,9 +241,11 @@ export function pngProblem(buf: Uint8Array, path: string): string | null {
216
241
 
217
242
  /**
218
243
  * A file that is not a PNG rigc can read, thrown by the readers that return a
219
- * value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s,
220
- * whole; a caller that owns a named failure — the gate, a `CompileError` —
221
- * reads the sentence instead of catching the throw (`readPngHeader`).
244
+ * value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s
245
+ * — or, from `readPlate`, `imageDataProblem`'s for a file whose header reads
246
+ * and whose image data does not decode (issue #1074) — whole; a caller that
247
+ * owns a named failure — the gate, a `CompileError` — reads the sentence
248
+ * instead of catching the throw (`readPngHeader`).
222
249
  */
223
250
  export class NotAPngError extends Error {
224
251
  readonly path: string;
package/src/spine_side.ts CHANGED
@@ -63,6 +63,21 @@ export const SURVEY_RUNTIME_TAIL =
63
63
  */
64
64
  export const SPINE_SIDE_ABSENT = 'the runtime side is not installed in this entry, which links nothing of spine-core';
65
65
 
66
+ /**
67
+ * What an entry that registered no Spine side adds after the tail: where the
68
+ * runtime is (issue #1079). On an install `bin/rigc.cjs` runs this entry
69
+ * wherever `@esotericsoftware/spine-core` does not resolve, so "the runtime is
70
+ * not linked" there means "it is not installed" — and a clone runs it as
71
+ * `bun cli_core.ts`, so both routes are named, each for its reader.
72
+ *
73
+ * ⚠️ Only the absent side says it. A runtime that is linked and fails to load
74
+ * (`./render.ts`, `./deformmeasure.ts`) is installed already, and "install it
75
+ * beside the package" would be the wrong sentence there.
76
+ */
77
+ export const SPINE_SIDE_ROUTE =
78
+ 'the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; ' +
79
+ 'from a source checkout, `bun cli.ts`';
80
+
66
81
  /** The one sentence a run that needs the runtime and cannot use it is refused in — `tail` says what the runtime is for. */
67
82
  export function spineRuntimeSentence(label: string, why: string, reason: string, tail: string): string {
68
83
  return `${label} is posed through spine-core (${why}), and the runtime could not be used: ${reason}. ${tail}`;
@@ -107,13 +122,13 @@ export function registerSpineSurvey(side: SpineSurveySide): void {
107
122
 
108
123
  /** The posing side, or a refusal of `label` (needed for `why`) naming the runtime as absent. */
109
124
  export function spinePosingFor(label: string, why: string): SpinePosingSide {
110
- if (posing === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, POSING_RUNTIME_TAIL));
125
+ if (posing === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, `${POSING_RUNTIME_TAIL}; ${SPINE_SIDE_ROUTE}`));
111
126
  return posing;
112
127
  }
113
128
 
114
129
  /** The survey side, or a refusal of `label` (needed for `why`) naming the runtime as absent. */
115
130
  export function spineSurveyFor(label: string, why: string): SpineSurveySide {
116
- if (survey === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, SURVEY_RUNTIME_TAIL));
131
+ if (survey === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, `${SURVEY_RUNTIME_TAIL}; ${SPINE_SIDE_ROUTE}`));
117
132
  return survey;
118
133
  }
119
134
 
@@ -153,6 +153,41 @@ function bundleName(plist: string): string | null {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * The editor naming its own version, as MEASURED on a licensed 4.3 editor on
158
+ * macOS 2026-10-02 (issue #1077). `--version` printed nine lines on stdout and
159
+ * none on stderr, in this shape:
160
+ *
161
+ * the launcher's banner `Spine Launcher <x.y.z> (<platform>)`
162
+ * the copyright line
163
+ * the operating system and its version
164
+ * `Starting: Spine <x.y.z> Professional`
165
+ * `Spine <x.y.z> Professional` <- this line
166
+ * `Licensed to:`
167
+ * the licensee's name
168
+ * the licensee's e-mail address
169
+ * `Complete.`
170
+ *
171
+ * 🚨 The tool used to print the last three of those, which are the licensee's
172
+ * name, e-mail and `Complete.` — personal data in a log that gets pasted into
173
+ * PR bodies and cards, and no version at all (issue #1040 found it). So the
174
+ * report keeps ONE line, the one the editor starts with its own name and a
175
+ * version, and nothing else from that output.
176
+ *
177
+ * ⚠️ Anchored at the start of the line and to a version straight after the
178
+ * word, which is what separates it from its neighbours: the launcher's banner
179
+ * has `Launcher` there (and is a different version), and the `Starting:` line
180
+ * does not start with `Spine`. A line that merely contains a version somewhere —
181
+ * the operating system's — is not the editor's.
182
+ */
183
+ const EDITOR_VERSION_LINE = /^Spine[ \t]+\d+\.\d+\.\d+\b[^\n]*$/m;
184
+
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();
189
+ }
190
+
156
191
  /** The trial naming itself in its own `--version` output, or null. */
157
192
  function trialSignalFromVersion(output: string): string | null {
158
193
  const banner = TRIAL_BANNER.exec(output);
@@ -981,7 +1016,8 @@ function main(): void {
981
1016
 
982
1017
  const ver = run(opts.editor, ['--version'], 60);
983
1018
  emit(` editor ${opts.editor}`);
984
- for (const line of ver.stdout.trim().split('\n').slice(-3)) emit(` ${line}`);
1019
+ // One line, never the output's tail: the tail is the licensee's (issue #1077).
1020
+ emit(` ${editorVersionLine(`${ver.stdout}\n${ver.stderr}`)}`);
985
1021
  // The second signal, and the only one that works on a platform whose trial
986
1022
  // path this repository has never seen: the binary's own banner. It costs no
987
1023
  // extra call — `--version` above is one the tool already made.
package/tools/plate.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { deflateSync, inflateSync } from 'node:zlib';
16
16
  import { readFileSync, writeFileSync } from 'node:fs';
17
17
  import { drawText, textWidth } from './font5x7.ts';
18
- import { assertPng } from '../src/png.ts';
18
+ import { assertPng, NotAPngError } from '../src/png.ts';
19
19
 
20
20
  export type RGBA = [number, number, number, number];
21
21
 
@@ -266,33 +266,27 @@ function toByte(sample: number, bitDepth: number): number {
266
266
  return Math.round((sample * 255) / ((1 << bitDepth) - 1));
267
267
  }
268
268
 
269
+ /** What `decodePng` has once the stream is inflated and before a scanline is unfiltered. */
270
+ interface InflatedPng {
271
+ width: number;
272
+ height: number;
273
+ bitDepth: number;
274
+ colourType: number;
275
+ channels: number;
276
+ pal: Uint8Array;
277
+ alphaTable: Uint8Array | null;
278
+ raw: Uint8Array;
279
+ }
280
+
269
281
  /**
270
- * Read a PNG back into a `Plate`, expanding whatever it is to straight RGBA.
271
- *
272
- * `src/png.ts` parses 26 bytes because that is all the COMPILER needs, and it
273
- * stays that way: the compiler never re-measures art. This
274
- * decoder is for the measuring tools, which is where art measurement belongs -
275
- * the same division that puts `mesh.center` in the manifest as a measured number
276
- * rather than as something the compiler derives at build time.
277
- *
278
- * ⭐ **Every colour type the gate accepts is decodable here** (issue #226). It
279
- * used to read colour types 2 and 6 only, which was the compiler's own old blind
280
- * spot rebuilt one step later: `A19` learned in #215 that indexed and greyscale
281
- * art carrying a `tRNS` chunk is ordinary transparent art — ImageMagick, PNG-8
282
- * export, GIMP indexed, aseprite and pngquant all write it — so such a part
283
- * builds and validates green, and would then have been refused by the one command
284
- * whose whole job is to show the author what they built. A wall removed at the
285
- * gate and rebuilt at the picture is not a wall removed.
286
- *
287
- * So the expansion happens here, at decode: a palette index becomes its `PLTE`
288
- * entry with its `tRNS` alpha, a greyscale sample becomes three equal channels,
289
- * and a `tRNS` colour on a type 0 or 2 file becomes alpha 0 on the pixels that
290
- * match it. Every caller above this line keeps seeing exactly one thing, RGBA.
291
- *
292
- * Interlaced files are still refused: Adam7 is a second sample layout rather than
293
- * a second sample format, and no tool in the corpus writes one.
282
+ * The half of `decodePng` that can fail: the chunk walk, the header's own
283
+ * checks, the inflate of every `IDAT` concatenated, and whether what came out
284
+ * is as long as the header says the image is (issue #1074). Everything after
285
+ * it — unfiltering, expanding to RGBA — reads bytes that are there and cannot
286
+ * refuse, so `imageDataProblem` runs exactly this and nothing else, and is the
287
+ * decoder's own answer rather than a second opinion beside it.
294
288
  */
295
- export function decodePng(buf: Uint8Array): Plate {
289
+ function inflatePng(buf: Uint8Array): InflatedPng {
296
290
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
297
291
  let at = 8;
298
292
  let width = 0;
@@ -329,6 +323,47 @@ export function decodePng(buf: Uint8Array): Plate {
329
323
  const alphaTable = trns;
330
324
 
331
325
  const raw = new Uint8Array(inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c)))));
326
+ // A stream that inflates clean and short is as undecodable as one that does
327
+ // not inflate: the rows past its end would be read as zeros, a picture the
328
+ // file never held. Longer is accepted — the spec's decoders ignore the excess.
329
+ const needed = height * (Math.ceil((width * channels * bitDepth) / 8) + 1);
330
+ if (raw.length < needed) {
331
+ throw new Error(
332
+ `its image data inflates to ${raw.length} byte(s), where a ${width}x${height} image of colour type ` +
333
+ `${colourType} at bit depth ${bitDepth} needs ${needed}`,
334
+ );
335
+ }
336
+ return { width, height, bitDepth, colourType, channels, pal, alphaTable, raw };
337
+ }
338
+
339
+ /**
340
+ * Read a PNG back into a `Plate`, expanding whatever it is to straight RGBA.
341
+ *
342
+ * `src/png.ts` parses 26 bytes because that is all the COMPILER needs, and it
343
+ * stays that way: the compiler never re-measures art. This
344
+ * decoder is for the measuring tools, which is where art measurement belongs -
345
+ * the same division that puts `mesh.center` in the manifest as a measured number
346
+ * rather than as something the compiler derives at build time.
347
+ *
348
+ * ⭐ **Every colour type the gate accepts is decodable here** (issue #226). It
349
+ * used to read colour types 2 and 6 only, which was the compiler's own old blind
350
+ * spot rebuilt one step later: `A19` learned in #215 that indexed and greyscale
351
+ * art carrying a `tRNS` chunk is ordinary transparent art — ImageMagick, PNG-8
352
+ * export, GIMP indexed, aseprite and pngquant all write it — so such a part
353
+ * builds and validates green, and would then have been refused by the one command
354
+ * whose whole job is to show the author what they built. A wall removed at the
355
+ * gate and rebuilt at the picture is not a wall removed.
356
+ *
357
+ * So the expansion happens here, at decode: a palette index becomes its `PLTE`
358
+ * entry with its `tRNS` alpha, a greyscale sample becomes three equal channels,
359
+ * and a `tRNS` colour on a type 0 or 2 file becomes alpha 0 on the pixels that
360
+ * match it. Every caller above this line keeps seeing exactly one thing, RGBA.
361
+ *
362
+ * Interlaced files are still refused: Adam7 is a second sample layout rather than
363
+ * a second sample format, and no tool in the corpus writes one.
364
+ */
365
+ export function decodePng(buf: Uint8Array): Plate {
366
+ const { width, height, bitDepth, colourType, channels, pal, alphaTable, raw } = inflatePng(buf);
332
367
  // The filter operates on BYTES, and its "left neighbour" is one whole pixel
333
368
  // back — rounded up to a byte, so sub-byte depths compare against the byte
334
369
  // immediately to the left.
@@ -430,6 +465,44 @@ export function readPlate(path: string): Plate {
430
465
  try {
431
466
  return decodePng(buf);
432
467
  } catch (err) {
433
- throw new Error(`cannot decode PNG ${path}: ${(err as Error).message}`);
468
+ // The decoder's refusal as the same sentence the gate prints (issue
469
+ // #1074), and as the class every command already refuses by name: it was
470
+ // a bare Error, which `render` and `build` (through a mesh part's trace)
471
+ // let through as a stack.
472
+ throw new NotAPngError(path, undecodableSentence(path, (err as Error).message));
473
+ }
474
+ }
475
+
476
+ /** What a PNG whose header reads and whose image data does not decode is, as one sentence. */
477
+ function undecodableSentence(path: string, decoder: string): string {
478
+ return (
479
+ `${path} is a PNG whose header and chunks read and whose image data cannot be decoded: the decoder stops ` +
480
+ `with "${decoder}", so no texel of it can be read. Re-export the file, or re-copy it whole`
481
+ );
482
+ }
483
+
484
+ /**
485
+ * Why the image data of a PNG that `pngProblem` accepts cannot be decoded, as
486
+ * one sentence — or `null` when it can (issue #1074).
487
+ *
488
+ * 🔒 **It is the decoder, not a check beside it**: it runs `inflatePng`, the
489
+ * half of `decodePng` that can refuse, and reports that refusal in the
490
+ * decoder's own words. `pngProblem` reads chunk headers and never inflates, so
491
+ * a page with an intact chunk walk over a broken compressed stream passed it,
492
+ * passed `A06`, and was found only by whichever reader decoded first — `A19`
493
+ * as `threw: cannot decode PNG …`, `render` and a mesh part's trace as a stack,
494
+ * and on a page `A19` does not scan (the base plate, or any page under the
495
+ * default profile) by nobody: such a build was written green.
496
+ *
497
+ * ⚠️ Inflate only, deliberately: measured over the 19 recipes' pages it is
498
+ * 68.9 ms against `validate()`'s 286.2 ms under `spine`, where a whole decode
499
+ * (`decodePng`, which unfilters and expands every texel) is 459.6 ms.
500
+ */
501
+ export function imageDataProblem(buf: Uint8Array, path: string): string | null {
502
+ try {
503
+ inflatePng(buf);
504
+ return null;
505
+ } catch (err) {
506
+ return undecodableSentence(path, (err as Error).message);
434
507
  }
435
508
  }