spine-rigc 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -533,9 +533,9 @@ first three work on any reference you have, and `bench` is a repository workflow
533
533
  and `bun run fetch-examples`. The reasoning behind them is in
534
534
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
535
535
 
536
- `build` and `validate` both default to `--profile spine` — the 29 validity rules, which
536
+ `build` and `validate` both default to `--profile spine` — the 30 validity rules, which
537
537
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
538
- adds all 44: the other 15 are one renderer's policy and one canvas budget's, and they
538
+ adds all 45: the other 15 are one renderer's policy and one canvas budget's, and they
539
539
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
540
540
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
541
541
  yourself: author under `--profile spine-html` and read the extra 15 as findings, and
@@ -681,7 +681,7 @@ letting `A17` blame the editor for the harness's own doing.
681
681
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
682
682
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
683
683
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
684
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 44 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
684
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 45 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
685
685
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
686
686
  | 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
687
687
  | 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
@@ -738,7 +738,7 @@ quality."* All six, with their verdicts, are in
738
738
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
739
739
 
740
740
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
741
- see, every rung, the run viewer, the 44 assertions and the selftest behind them — is
741
+ see, every rung, the run viewer, the 45 assertions and the selftest behind them — is
742
742
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
743
743
  Live rung status is
744
744
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -169,7 +169,7 @@ What the flags mean:
169
169
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
170
170
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
171
171
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
172
- | `--profile` | `spine` = the 29 validity rules (**the default**) · `spine-html` = all 44, opt-in |
172
+ | `--profile` | `spine` = the 30 validity rules (**the default**) · `spine-html` = all 45, opt-in |
173
173
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
174
174
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote`, so is a name that only *some* candidates have |
175
175
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
@@ -500,6 +500,23 @@ the first:
500
500
  | `JUDGE` | the skeleton cannot answer and somebody has to: the stage, and each animation's duration |
501
501
  | `LOSS` | the skeleton's spelling and rigc's differ, on purpose, and the line says how. A path attachment's `lengths` is the one that matters — it is `PathConstraint`'s own four-sample measurement rather than an arc length (#560), so a transcribed one would freeze whatever produced the source. The header ones are cheaper: `HEADER_BOOKKEEPING` for a field the spec has no home for, `HEADER_REDERIVED` for the version string, `HEADER_ORIGIN` for an origin the source left to the format and the rebuild writes out (#622) |
502
502
 
503
+ ⛔ **It reads one generation of the format, and a file from another one ends loud.**
504
+ Spine data is locked to the generation that exported it and a mismatch does not
505
+ throw: 4.3 takes constraints from the top-level `constraints` array alone, so a
506
+ 4.0–4.2 file's `ik`/`transform`/`path`/`physics` arrays load as nothing at all — 1,302
507
+ shipped skeletons parsed on a 4.3 runtime and loaded 0 of 8,672 constraints
508
+ ([#706](https://github.com/firejune/rigc/issues/706) row 1). So `ingest` reads
509
+ `skeleton.spine` before it reads a field of the file. A file from another generation is
510
+ a `BLOCK GENERATION_UNSUPPORTED` naming the generation, the string it was read from,
511
+ and what a 4.3 reader loses **on that file**: the constraints parked in those arrays
512
+ counted by kind, the bones carrying 4.2's `transform` where 4.3 spells `inherit`, and
513
+ the physics constraints omitting `inertia`/`damping`, whose default is not the same
514
+ number in the two. A label naming no generation rigc knows — or a header stating none —
515
+ is a `BLOCK GENERATION_UNKNOWN`, never rounded to the nearest: a catalog that rounded
516
+ handed 19 skeletons labelled `3.8.99` a 4.2 runtime and every one of them posed as NaN
517
+ (row 7). Reading a file with *that generation's own* defaults is #706's item 2 and is
518
+ not in this tool — re-export as 4.3, or transcribe by hand ([INGEST.md](INGEST.md) §2).
519
+
503
520
  📝 **Do not delete the `note`.** Both written specs carry one saying the file is
504
521
  decompiled and naming the skeleton it came from. A decompiled spec is
505
522
  indistinguishable from an authored one by inspection, every gate here calls it green —
@@ -1220,7 +1237,12 @@ the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`). Measured
1220
1237
  a forged skeleton — a link declaring 5 uvs, 3 triangles, `hull: 5` and
1221
1238
  `edges: [0, 2]` beside a 4-vertex source loaded with the **source's** 8-long
1222
1239
  `worldVerticesLength`, 6 triangles, `hullLength` 8 and 10 edges. Nothing the author
1223
- wrote reached anything and nothing said so.
1240
+ wrote reached anything and nothing said so. ⇒ The same fact is held against a
1241
+ skeleton rigc did **not** write, where the compiler never sees the spec:
1242
+ `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` (§5.2) names the attachment, the
1243
+ keys and whose geometry is drawn instead, and `ingest` reports one as
1244
+ `ATTACHMENT_LINK_GEOMETRY` before dropping it
1245
+ ([INGEST §2.0](INGEST.md)) — [#710](https://github.com/firejune/rigc/issues/710).
1224
1246
 
1225
1247
  🚫 **A chain is refused, and so is a link to itself.** A `source` that names
1226
1248
  another linked mesh resolves in the order the file was read: measured through
@@ -4769,7 +4791,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4769
4791
  | `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`) |
4770
4792
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) — as it is for `A06`, `A19` and `A27`; see `A07` ([#608](https://github.com/firejune/rigc/issues/608)) |
4771
4793
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
4772
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4794
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4773
4795
  | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not ([#684](https://github.com/firejune/rigc/issues/684)). Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4774
4796
  | `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 |
4775
4797
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
@@ -4794,6 +4816,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4794
4816
  | `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first ([#540](https://github.com/firejune/rigc/issues/540)). The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
4795
4817
  | `A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER` | both | a slider whose animation keys a property of a constraint **at or before it** in `constraints` (§3.5.2) — a slider's `mix` or `time`, an ik or transform mix, a path `position`, `spacing` or `mix`, any physics value. That array is the update order for every kind, and each constraint reads its own applied pose when its turn comes — `Slider.update` takes `mix` as the alpha it applies with and `time` as the time it applies at, `PhysicsConstraint.update` returns on `mix` 0 before reading the rest — so the key lands after the only read of it and `Posed.resetConstrained` discards it before the next frame: what the driven constraint drives is dead at every position of the driving dial, although its pose still holds the number ([#658](https://github.com/firejune/rigc/issues/658), [#665](https://github.com/firejune/rigc/issues/665)). The detail names the slider, the driven constraint with its kind, both array indices, the property, the runtime class whose `update` reads it, and the animation the key sits in. Fix by moving the driver earlier, or by keying that property from a slider that already is. **The two indices equal is the same failure**: a slider cannot key its own `mix` or `time`, and one muted at setup that keys its own `mix` up never applies anything at all — `A37` is silent there, because it asks whether *an* animation keys the mix and not which one. **Two shapes it deliberately leaves out**, both measured: a `physics` `reset` key, which fires on a crossed frame time and so never fires from a slider at all, in either order — the reorder would repair nothing; and a physics timeline naming no constraint, which is every physics constraint declaring that property global and IS refused for the ones already run. Disjoint from `A40` by construction: `A40` asks who writes a shared property last and excludes every slider whose `mix` is keyed, this asks whether anything reads what was written. **SKIP** when the skeleton declares no slider, and when no slider's animation keys a constraint property — that SKIP names any `reset` keys it found — a pass means a driver and a driven were compared |
4796
4818
  | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` — there is then no two-colour tint to read back |
4819
+ | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read ([#710](https://github.com/firejune/rigc/issues/710)) |
4797
4820
 
4798
4821
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
4799
4822
  clauses are gated by profile.
package/docs/INGEST.md CHANGED
@@ -476,6 +476,19 @@ a default the source left to the format and the rebuild writes out). A blocker e
476
476
  non-zero and still writes both files. **Every code it can print has a row at the end
477
477
  of this section**, with its gutter, its effect on the exit code and what to do.
478
478
 
479
+ ⛔ **And it reads one generation.** Spine data is locked to the generation that
480
+ exported it, and a mismatch is silent rather than loud: 4.3 takes constraints from the
481
+ top-level `constraints` array alone, so a 4.0–4.2 file's `ik`/`transform`/`path`/
482
+ `physics` arrays load as nothing at all — 1,302 shipped skeletons parsed on a 4.3
483
+ runtime and loaded 0 of 8,672 constraints
484
+ ([#706](https://github.com/firejune/rigc/issues/706) row 1). So `ingest` reads
485
+ `skeleton.spine` before it reads a field of the file, and a file from another
486
+ generation is a blocker naming that generation and counting, **on that file**, what a
487
+ 4.3 reader loses by it. Reading such a file with *that generation's own* defaults is a
488
+ different job — #706's item 2, a per-generation table extracted by machine from each
489
+ runtime's `SkeletonJson` — and it is not in this tool, which is why the finding points
490
+ at the policy rather than implying the file was read.
491
+
479
492
  **Two values are not in a skeleton**, so `ingest` asks rather than guesses:
480
493
 
481
494
  - **the stage** (`skeleton.width`/`height`) — `--stage x,y,w,h` is how you supply one
@@ -540,6 +553,7 @@ is the one failure a comparison of two sets cannot show you.
540
553
  | --- | --- | --- | --- | --- |
541
554
  | `ANIMATION_GROUP` | `BLOCK` | 1 | the animation carries a group the motion spec has no home for. The detail names the ten it does carry. `drawOrderFolder` is the group to know about: the runtime reads it and builds a timeline from it, and no export in this corpus carries one | transcribe that group by hand (§2), or accept that the rebuild does not carry it |
542
555
  | `ATTACHMENT_<TYPE>` | `BLOCK` | 1 | an attachment of a type rigc does not emit; the code is composed from the type, so on the one type left it reads `ATTACHMENT_POINT`. rigc emits region, mesh, linkedmesh, boundingbox, clipping and path — `linkedmesh` since [#691](https://github.com/firejune/rigc/issues/691), and `point` is the remaining deferred type | the rebuild will not have that attachment at all. `docs/SPEC_COVERAGE.md` part 1-6 says what a deferred type would carry |
556
+ | `ATTACHMENT_LINK_GEOMETRY` | `LOSS` | 0 | a **linked mesh** that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by nothing at all and the attachment draws the geometry its `source` names; the rig spec has no home for them either, because `build` refuses geometry on a link by name. The detail lists the keys and the source. Until [#710](https://github.com/firejune/rigc/issues/710) the rebuild dropped them with no line at all, so an `ingest` that normalised somebody's file said nothing about it | nothing. The rebuild is the mesh the runtime was already drawing — and if those keys were the geometry you meant, take `source` off and author it as a mesh of its own. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` is the same fact at the gate |
543
557
  | `ATTACHMENT_NAME` | `LOSS` | 0 | the attachment states a `name` and only **one** skin fills the placeholder, so rigc writes none — it composes `<skin>/<placeholder>` exactly where a placeholder is contested | nothing, unless something downstream looks that attachment up by the name the source gave it |
544
558
  | `ATTACHMENT_SEQUENCE` | `BLOCK` | 1 | the attachment carries a `sequence` block — a numbered image series — which the rig spec cannot say | the rebuild draws the single region the attachment names; the frames have to be driven some other way |
545
559
  | `ATTACHMENT_TIMELINE` | `BLOCK` | 1 | an attachment timeline other than `deform`, which is the only one the motion spec carries | transcribe it, or accept that the rebuild does not play it |
@@ -549,9 +563,11 @@ is the one failure a comparison of two sets cannot show you.
549
563
  | `CONSTRAINT_KEY_RESTATED` | `LOSS` | 0 | an `ik` or `transform` track whose keys do not all state the same fields. The motion spec takes one field set per track, so a field **any** key states is written on **every** key at the value the parser would have read there | nothing. Same values, larger file — the rebuild plays what the source plays |
550
564
  | `CONSTRAINT_TYPE` | `BLOCK` | 1 | a constraint whose `type` is none rigc knows, so the whole constraint is dropped rather than approximated | the rebuild has no such constraint; check the spelling before assuming the type is unsupported |
551
565
  | `DURATION` | `JUDGE` | 0 | skeleton JSON has no duration field at all. The largest key time is used, which is what a runtime plays to — and wrong for an animation that holds its last pose past its last key | if you know the real number, edit `duration` in the motion spec. It costs nothing: the declared duration is checked against the compiled keys |
566
+ | `GENERATION_UNKNOWN` | `BLOCK` | 1 | `skeleton.spine` names no generation rigc knows, or the header states none at all. A version is read as its LEADING `major.minor` token — a down-export writes `4.0-from-4.1.24`, which is 4.0 data from a 4.1 editor — and it is never rounded to the nearest generation: a catalog that rounded handed 19 skeletons labelled `3.8.99` a 4.2 runtime and every one posed as NaN ([#706](https://github.com/firejune/rigc/issues/706) row 7) | check the string against the file you were handed. A real generation rigc does not list belongs on #706 item 1, with the string beside it |
567
+ | `GENERATION_UNSUPPORTED` | `BLOCK` | 1 | the file is Spine data from another generation and this reader reads 4.3. The detail names the generation, the string it was read from, and what a 4.3 reader loses on **this** file: constraints parked in the top-level `ik` / `transform` / `path` / `physics` / `slider` arrays 4.3 folded into `constraints` and this reader never opens (row 1), bones carrying 4.2's `transform` where 4.3 spells `inherit` (row 6), and physics constraints omitting `inertia` / `damping`, whose default is not the same number in 4.2 as in 4.3 (row 4) | re-export the file as 4.3 from an editor of its own generation, or transcribe it by hand (§2). Reading it with **that generation's** defaults is #706 item 2 and is not in this tool |
552
568
  | `HEADER_BOOKKEEPING` | `LOSS` | 0 | a header field the editor writes and the rig spec has no home for — `hash`, `audio`. Dropped, and nothing reads it back | nothing. It is one of the three differences §2.3 measures on every editor export |
553
569
  | `HEADER_ORIGIN` | `LOSS` | 0 | the source declares an extent and omits `x`/`y`. Inside a declared extent an omitted origin **is** 0, so the spec states it — and the rebuild then spells two fields the source did not | nothing. Same box, different bytes — which is why byte identity is not the claim for an export that takes this branch |
554
- | `HEADER_REDERIVED` | `LOSS` | 0 | `skeleton.spine`: the rebuild writes the version of the runtime rigc links. The line says whether that is the same string the source states | nothing — but read the line: a 4.2 export rebuilds as 4.3 in that one field |
570
+ | `HEADER_REDERIVED` | `LOSS` | 0 | `skeleton.spine`: the rebuild writes the version of the runtime rigc links. The line says whether that is the same string the source states | nothing — but read the line: a 4.2 export rebuilds as 4.3 in that one field, and a source from another generation raises `GENERATION_UNSUPPORTED` beside it, which is the blocker about the DATA rather than about the string |
555
571
  | `IK_KEY_FIELD` | `BLOCK` | 1 | a key field on an `ik` timeline that is not part of its shape | check the spelling; an unknown field is dropped from the rebuilt track |
556
572
  | `NO_STAGE` | `BLOCK` `JUDGE` | 1 | the skeleton declares no stage. It is a blocker with no `--stage`, and a **judgement** — exit 0 — when `--stage x,y,w,h` supplies one, because nothing measured the box you gave it | supply the box from the project the file came from. It cannot be derived: posing the rig gives the animated extent, which is a different number |
557
573
  | `PATH_LENGTHS` | `LOSS` | 0 | the source states a path attachment's `lengths` and rigc re-measures it as `PathConstraint` does | nothing. Dropping it is the correct reading: the field is the runtime's own four-sample forward difference, not an arc length |
@@ -736,6 +736,7 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
736
736
  | `A11_NO_CLIPPING_ATTACHMENTS` | **renderer-profile** | clipping attachments — "the renderer skips them silently" |
737
737
  | `A12_NO_DARK_COLOR` | **renderer-profile** | slot `dark`, `rgba2`/`rgb2` timelines — "parsed, then ignored". ⚠️ rigc **emits** the first two; a renderer that drops a construct is what a profile is for, not a reason not to emit it |
738
738
  | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | validity | a slot `dark` the parser drops or reads as NaN, an `rgba2` timeline on a slot with no dark colour to pose, or a key whose posed light/dark is not what it states |
739
+ | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | validity | a linked mesh, in either spelling, that also states `uvs`, `triangles`, `vertices`, `hull` or `edges` — keys the `source` branch returns before reading, so the file says one mesh and every runtime draws its source's |
739
740
  | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
740
741
  | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
741
742
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Which Spine data generation a skeleton's own version string names.
3
+ *
4
+ * ## Why this is one function and not a regex per caller
5
+ *
6
+ * Spine data is locked to the generation that exported it, and a mismatch fails
7
+ * **silently**: 4.3 takes constraints from the top-level `constraints` array
8
+ * alone, so a 4.0–4.2 file parses clean with none of them — measured across
9
+ * 1,302 shipped skeletons that loaded 0 of 8,672 constraints
10
+ * ([#706](https://github.com/firejune/rigc/issues/706) row 1). rigc is where
11
+ * that knowledge lives for the tools around it (#706 *Ownership*), and living
12
+ * in one place means one reader of `skeleton.spine`:
13
+ * `A16_SKELETON_VERSION_4_3` asks this function for its verdict, and `ingest`
14
+ * asks it before it reads a field of the file.
15
+ *
16
+ * ## What is here, and what is deliberately not
17
+ *
18
+ * This is #706's item **1**, and nothing else. Item **2** — the per-generation
19
+ * table of key renames, array shapes and `getValue(map, key, default)` defaults,
20
+ * extracted by machine from each runtime branch's `SkeletonJson` — is not here,
21
+ * and neither is item **4**'s conversion utility. So this module reads a string
22
+ * and names a generation. It does not read a file, apply another generation's
23
+ * defaults, or convert anything, and a caller that meets data from another
24
+ * generation has to say so out loud rather than read it anyway.
25
+ *
26
+ * ## The grammar, and the two rules in it
27
+ *
28
+ * A version string is `MAJOR.MINOR`, an optional chain of `-from-MAJOR.MINOR`,
29
+ * then an optional `.PATCH` and an optional `-SUFFIX` after that. The shipped
30
+ * strings #706 lists are `3.8.99`, `4.0.33`, `4.0-from-4.1.24`,
31
+ * `4.0-from-4.1-from-4.2.29`, `4.1-from-4.2.33`, `4.2.09-beta`, `4.2.43`,
32
+ * `4.3.26` and `4.3.75-beta`.
33
+ *
34
+ * 1. **The leading token is the generation.** `4.0-from-4.1.24` is 4.0 data
35
+ * written by a 4.1 editor — a *down-export* — and all 418 such-or-plain 4.0
36
+ * files and 101 4.1 files in #706's corpus load and render on the 4.0 / 4.1
37
+ * runtimes with 0 failures. Reading the trailing token would hand them the
38
+ * wrong runtime, which is row 7's defect with extra steps.
39
+ * 2. **Every token has to be a generation this module knows, and the chain has
40
+ * to ascend.** A down-export comes *from* a newer editor, so `4.0-from-4.1`
41
+ * is a down-export and `4.3-from-4.2` is not a version string this reader
42
+ * can account for. Both rules answer `null`, which is rule 3.
43
+ * 3. **Unknown is `null`, never the nearest.** A catalog builder gave 19
44
+ * skeletons labelled `3.8.99` the nearest runtime it had; they loaded, and
45
+ * posed 238 of 248 bones as NaN (#706 row 7). `null` is what a caller has to
46
+ * act on, and it is why this returns a union rather than a number to compare.
47
+ *
48
+ * ⭐ Rules 2 and 3 together are also what keeps `A16`'s accepted set **exactly**
49
+ * what its own regex accepted before this module existed: 4.3 is the highest
50
+ * generation here, nothing can ascend above it, so no `-from-` string is ever
51
+ * read as 4.3 and `A16` still accepts `4.3`, `4.3.<patch>` and
52
+ * `4.3.<patch>-<suffix>` and those alone. The cost is stated rather than hidden:
53
+ * a future editor down-exporting as `4.3-from-4.4.1` reads as `null` here and is
54
+ * refused by name until that generation is added — which is #706 policy 1's own
55
+ * answer ("an unknown generation gets no runtime") rather than a gap in this one.
56
+ *
57
+ * ## Purity
58
+ *
59
+ * No clock, no randomness, no filesystem, no network, no `spine-core`. It reads
60
+ * strings and small plain objects and nothing else.
61
+ */
62
+
63
+ /** A generation of Spine data — the `MAJOR.MINOR` pair a runtime is locked to. */
64
+ export type SpineGeneration = '3.8' | '4.0' | '4.1' | '4.2' | '4.3';
65
+
66
+ /**
67
+ * Every generation this module knows, oldest first.
68
+ *
69
+ * The order is load-bearing twice: a down-export chain has to ascend through it,
70
+ * and a caller listing "the generations rigc knows" reads it here rather than
71
+ * typing five strings next to a sixth.
72
+ */
73
+ export const SPINE_GENERATIONS: readonly SpineGeneration[] = ['3.8', '4.0', '4.1', '4.2', '4.3'];
74
+
75
+ /**
76
+ * `MAJOR.MINOR`, then the `-from-` chain, then the patch and its suffix.
77
+ *
78
+ * Anchored at both ends on purpose: `4.30` and `4.3.1.2` are not version strings
79
+ * and a partial match would read them as 4.3. The suffix shape is the one the
80
+ * editor writes for a pre-release (`4.3.75-beta`, which every one of the twelve
81
+ * official example exports declares) and is the same one `A16`'s own regex
82
+ * carried, character for character, before this module took it over.
83
+ */
84
+ const VERSION_STRING = /^(\d+\.\d+)((?:-from-\d+\.\d+)*)(?:\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.+-]*)?)?$/;
85
+
86
+ /**
87
+ * The generation a `skeleton.spine` string names, or `null` for one this module
88
+ * cannot account for.
89
+ *
90
+ * `null` is never the nearest generation and never a guess — see rule 3 above.
91
+ */
92
+ export function spineGeneration(version: string): SpineGeneration | null {
93
+ const match = VERSION_STRING.exec(version);
94
+ if (match === null) return null;
95
+ const chain = match[2] === '' ? [] : match[2].split('-from-').slice(1);
96
+ const known = SPINE_GENERATIONS as readonly string[];
97
+ const steps = [match[1], ...chain].map((token) => known.indexOf(token));
98
+ if (steps.some((at) => at < 0)) return null;
99
+ for (let i = 1; i < steps.length; i++) if (steps[i] <= steps[i - 1]) return null;
100
+ return SPINE_GENERATIONS[steps[0]];
101
+ }
102
+
103
+ /**
104
+ * The constraint kinds a skeleton can carry as a **top-level array**, which 4.3
105
+ * folded into one `constraints` array with a `type` on each entry.
106
+ *
107
+ * 4.3 reads `constraints` and nothing else, so an array under any of these names
108
+ * loads without an error and the constraints in it are simply not there — #706
109
+ * row 1, and what `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` refuses in emitted
110
+ * data. `slider` never had a top-level form (sliders are 4.3's own), and it is
111
+ * on this list for the same reason as the other four: a constraint parked
112
+ * outside `constraints` vanishes whatever its kind, and a list with a hole in it
113
+ * is a rule with a hole in it.
114
+ */
115
+ export const TOPLEVEL_CONSTRAINT_ARRAYS: readonly string[] = ['ik', 'transform', 'path', 'physics', 'slider'];
116
+
117
+ /**
118
+ * The bone key 4.2 spelled `transform` and 4.3 spells `inherit`.
119
+ *
120
+ * The old key does not throw and does not warn — it is an unknown field, so the
121
+ * bone falls back to Normal inheritance (#706 row 6, and
122
+ * `A02_NO_BONE_TRANSFORM_KEY`).
123
+ */
124
+ export const LEGACY_BONE_INHERIT_KEY = 'transform';
125
+
126
+ /**
127
+ * The physics-constraint fields whose **omitted default** is not the same number
128
+ * in 4.2 as in 4.3.
129
+ *
130
+ * JSON omits a field equal to the parser's default, so the same file means two
131
+ * different constraints under two readers: `inertia` and `damping` both default
132
+ * to 1 in 4.2's `SkeletonJson` and to 0.5 and 0.85 in 4.3's (#706 row 4, which
133
+ * also counts 111 shipped constraints omitting `inertia`). The numbers are not
134
+ * repeated here deliberately — a hand-copied table is what #706 policy 3 exists
135
+ * to refuse, and item 2's generated table is where they belong. What a caller
136
+ * needs from this list is which fields to *count*, and that is what it is.
137
+ */
138
+ export const PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED: readonly string[] = ['inertia', 'damping'];
package/src/ingest.ts CHANGED
@@ -47,6 +47,13 @@
47
47
  import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
48
48
  import { CompileError } from './errors.ts';
49
49
  import { CHANNELS_BY_KIND } from './timelines.ts';
50
+ import {
51
+ LEGACY_BONE_INHERIT_KEY,
52
+ PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED,
53
+ SPINE_GENERATIONS,
54
+ spineGeneration,
55
+ TOPLEVEL_CONSTRAINT_ARRAYS,
56
+ } from './generation.ts';
50
57
  import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
51
58
  import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
52
59
  import type { MotionSpec } from './types.ts';
@@ -404,6 +411,26 @@ const HEADER_REDERIVED = ['spine'];
404
411
  /** The attachment types this module inverts. Everything else is refused by name. */
405
412
  const ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'clipping', 'path'];
406
413
 
414
+ /**
415
+ * The geometry keys a LINKED mesh may state and the parser never reads
416
+ * (issue #710).
417
+ *
418
+ * `readAttachment` returns from the `source` branch at `SkeletonJson.js:586`,
419
+ * before `map.uvs` is touched, so a link carrying any of these is a file that
420
+ * says one mesh while every runtime draws its source's. The rig spec cannot hold
421
+ * them either — `buildRigLinkedMesh` refuses geometry on a link by name — so a
422
+ * rebuild that carried one would be a spec `build` refuses, and a rebuild that
423
+ * dropped it in silence would be this module normalising somebody's file without
424
+ * saying so.
425
+ *
426
+ * ⚠️ A second list beside `src/validate.ts`'s, deliberately: that module links
427
+ * spine-core and this one must not, so importing it would pull the runtime into
428
+ * every `ingest`. What holds the two equal is a RUN rather than a shared
429
+ * constant — one forged skeleton through both, with the keys `A44` names and the
430
+ * keys this finding names compared as sets.
431
+ */
432
+ const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'];
433
+
407
434
  /**
408
435
  * The slot timelines the motion spec carries — `compileTrack`'s own table,
409
436
  * rather than a second list of the same two names.
@@ -548,6 +575,12 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
548
575
  const root = obj(skeleton);
549
576
  const boneNames: string[] = arr(root.bones).map(nameOf);
550
577
 
578
+ // -- the generation -------------------------------------------------------
579
+ // 🚨 First, because every walk below reads the file as 4.3 and a file from
580
+ // another generation is one this module cannot honestly claim to have read
581
+ // (issue #706 item 3). It records; it does not refuse — see `readGeneration`.
582
+ readGeneration(root, note);
583
+
551
584
  // -- header ---------------------------------------------------------------
552
585
  // 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
553
586
  // stage is the only value in this whole module that a skeleton cannot answer
@@ -716,6 +749,146 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
716
749
 
717
750
  type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
718
751
 
752
+ /**
753
+ * The generation of the data this module inverts, read off the version the
754
+ * emitter writes rather than typed beside it.
755
+ *
756
+ * ⚠️ `null` here would be a compiler emitting a version string this repository's
757
+ * own detector cannot read, and the comparison below is written so that it
758
+ * blocks every file rather than none — a reader that cannot say what it reads
759
+ * cannot certify anything. `runGenerationSuite`'s positive control is what says
760
+ * out loud that it is not null.
761
+ */
762
+ const READER_GENERATION = spineGeneration(SPINE_VERSION);
763
+
764
+ /** Up to six names, so one finding cannot print a hundred. */
765
+ function spellSome(names: readonly string[]): string {
766
+ const shown = names.slice(0, 6).map((name) => `"${name}"`).join(', ');
767
+ return names.length > 6 ? `${shown} +${names.length - 6} more` : shown;
768
+ }
769
+
770
+ /**
771
+ * What a 4.3 reader loses on THIS file, counted on this file.
772
+ *
773
+ * 🚨 Three shapes, and they are the three #706 measured rather than three this
774
+ * module thought of: constraints parked in the top-level arrays 4.3 folded away
775
+ * (row 1 — 1,302 shipped skeletons parsed and loaded 0 of 8,672 constraints),
776
+ * bones carrying the key 4.3 renamed (row 6), and physics constraints omitting a
777
+ * field whose default is not the same number in 4.2 as in 4.3 (row 4).
778
+ *
779
+ * ⚠️ It is a MEASUREMENT and not an inventory: a construct none of the three
780
+ * describes is lost without being counted here, which is why the empty case says
781
+ * so rather than saying nothing was lost.
782
+ */
783
+ function generationLosses(root: JsonObject): string[] {
784
+ const out: string[] = [];
785
+
786
+ const parked = TOPLEVEL_CONSTRAINT_ARRAYS.map((kind) => [kind, arr(root[kind]).length] as const).filter(
787
+ ([, count]) => count > 0,
788
+ );
789
+ const parkedTotal = parked.reduce((total, [, count]) => total + count, 0);
790
+ if (parkedTotal > 0) {
791
+ out.push(
792
+ `${parkedTotal} constraint(s) sit in top-level arrays (${parked.map(([kind, count]) => `${kind} ${count}`).join(', ')}) ` +
793
+ 'and this reader takes constraints from "constraints" alone, so it reads none of them and the rebuilt rig has none',
794
+ );
795
+ }
796
+
797
+ const renamed = arr(root.bones)
798
+ .filter((bone) => isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone)
799
+ .map((bone) => nameOf(bone));
800
+ if (renamed.length > 0) {
801
+ out.push(
802
+ `${renamed.length} bone(s) carry "${LEGACY_BONE_INHERIT_KEY}" where 4.3 spells "inherit" (${spellSome(renamed)}), ` +
803
+ 'each dropped as a field the rig spec has no home for — the BONE_FIELD line beside this one — so the ' +
804
+ 'rebuilt bone inherits Normally',
805
+ );
806
+ }
807
+
808
+ const physics = [...arr(root.physics), ...arr(root.constraints).filter((one) => isObj(one) && one.type === 'physics')].filter(
809
+ isObj,
810
+ );
811
+ const omitting = PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED.map(
812
+ (field) => [field, physics.filter((one) => one[field] === undefined).length] as const,
813
+ ).filter(([, count]) => count > 0);
814
+ if (omitting.length > 0) {
815
+ out.push(
816
+ `${physics.length} physics constraint(s), of which ${omitting.map(([field, count]) => `${count} omit "${field}"`).join(' and ')} — ` +
817
+ "JSON omits a field equal to the parser's default and that default is NOT the same number in 4.2 as in 4.3 " +
818
+ '(#706 row 4), so the omission means one rig there and a different one here',
819
+ );
820
+ }
821
+ return out;
822
+ }
823
+
824
+ /**
825
+ * The generation, read before a field of the file is.
826
+ *
827
+ * 🚨 **The silence this converts into a name was measured on the branch point.**
828
+ * A 4.3 emit with its constraints moved into the top-level arrays 4.2 kept them
829
+ * in came back through `ingest` as a rig spec with **zero** constraints and
830
+ * **no finding about them at all**; the only blocker was `BONE_FIELD`, about the
831
+ * bone key. A 3.8 label produced one `LOSS HEADER_REDERIVED` line and exit 0.
832
+ * That is the shape this whole module exists to refuse — a decompiler that is
833
+ * quiet about what it dropped.
834
+ *
835
+ * ⭐ **One code with the generation in the sentence, rather than one code per
836
+ * generation.** `IG25` derives `docs/INGEST.md` §2.0's finding table by reading
837
+ * every `note` call in this file for a code matching `[A-Z_]+`, and compares it
838
+ * against rows matched with `[A-Z_<>]+`. A composed `GENERATION_${generation}`
839
+ * would read `GENERATION_3.8` at runtime — digits and a dot — so the call would
840
+ * be one the scan cannot resolve and the code would be missing from BOTH sides
841
+ * of that comparison, which is the one failure comparing two sets cannot report.
842
+ *
843
+ * ⚠️ The same scan counts its own population with a second, dumber pattern over
844
+ * the raw text, comments included — so a prose mention of that call spelled with
845
+ * its opening bracket raises the count without raising the sites, and `IG25`
846
+ * goes red on a file with nothing wrong in it. It did, on the first green run of
847
+ * this change: **25 of 26 read**, the 26th being this very paragraph.
848
+ *
849
+ * ⚠️ It does NOT refuse the file. Everything here is a finding and both specs
850
+ * are still written, for the reason `IngestFinding` states: a spec plus a list
851
+ * of what is missing from it beats no spec. The exit code is the caller's and it
852
+ * is 1, because this is a `blocker`.
853
+ */
854
+ function readGeneration(root: JsonObject, note: Note): void {
855
+ const declared = obj(root.skeleton).spine;
856
+ const generation = typeof declared === 'string' ? spineGeneration(declared) : null;
857
+ if (generation !== null && generation === READER_GENERATION) return;
858
+ const stated = typeof declared === 'string' ? `${JSON.stringify(declared)}` : 'no `skeleton.spine` at all';
859
+ const reads = READER_GENERATION ?? '(none — this build\'s own version string is unreadable)';
860
+ const losses = generationLosses(root);
861
+ const measured =
862
+ losses.length > 0
863
+ ? `Measured on this file: ${losses.join('; ')}.`
864
+ : 'Measured on this file: no constraint in a top-level array, no bone carrying ' +
865
+ `"${LEGACY_BONE_INHERIT_KEY}", and no physics constraint omitting a default that moved — which is three ` +
866
+ 'shapes counted and not a guarantee that nothing else differs.';
867
+ if (generation === null) {
868
+ note(
869
+ 'blocker',
870
+ 'GENERATION_UNKNOWN',
871
+ 'skeleton.spine',
872
+ `the file states ${stated} and no Spine generation matches it. A version is read as its LEADING major.minor ` +
873
+ 'token — a down-export states "4.0-from-4.1.24", which is 4.0 data from a 4.1 editor — and the generations ' +
874
+ `rigc knows are ${SPINE_GENERATIONS.join(', ')}; this reader reads ${reads}. It is NOT read as the nearest ` +
875
+ 'one: a catalogue that handed 19 skeletons labelled "3.8.99" the nearest runtime it had loaded every one of ' +
876
+ `them and posed 238 of 248 bones as NaN (issue #706 row 7). ${measured}`,
877
+ );
878
+ return;
879
+ }
880
+ note(
881
+ 'blocker',
882
+ 'GENERATION_UNSUPPORTED',
883
+ 'skeleton.spine',
884
+ `the file states ${stated}, which is Spine ${generation} data, and this reader reads Spine ${reads} only — it ` +
885
+ `inverts a ${SPINE_VERSION} emitter. A generation mismatch does not throw; it drops what the newer format ` +
886
+ `moved. ${measured} Reading the file with ${generation}'s OWN defaults is issue #706 item 2 — a ` +
887
+ 'per-generation table extracted by machine from each runtime\'s `SkeletonJson` — and is not in this tool. ' +
888
+ `Re-export from a ${reads} editor, or transcribe the file by hand (docs/INGEST.md §2).`,
889
+ );
890
+ }
891
+
719
892
  /**
720
893
  * Does this header declare a stage?
721
894
  *
@@ -939,6 +1112,20 @@ function ingestAttachment(
939
1112
  // writing one the source omitted would be a rebuild that says more than the
940
1113
  // file did — and `buildRigLinkedMesh` drops it again on the way back out.
941
1114
  for (const field of ['slot', 'skin', 'timelines', 'color']) if (att[field] !== undefined) out[field] = att[field];
1115
+ const dropped = LINKED_MESH_UNREAD_KEYS.filter((field) => att[field] !== undefined);
1116
+ if (dropped.length > 0) {
1117
+ note(
1118
+ 'lossy',
1119
+ 'ATTACHMENT_LINK_GEOMETRY',
1120
+ at.where,
1121
+ `the attachment is a LINKED mesh and states ${dropped.map((field) => `\`${field}\``).join(', ')}, which the ` +
1122
+ 'parser reads with nothing at all: it returns from the `source` branch before `readVertices` ' +
1123
+ `(\`SkeletonJson.ts:582-586\`), so what this attachment draws is the geometry of ${JSON.stringify(att.source)}. ` +
1124
+ `The rebuild drops ${dropped.length === 1 ? 'it' : 'them'} — the rig spec refuses geometry on a link by name, ` +
1125
+ 'and carrying it would write a spec `build` will not take. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` ' +
1126
+ 'is the same fact held against the source file',
1127
+ );
1128
+ }
942
1129
  } else if (type === 'region') {
943
1130
  carryArt();
944
1131
  for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
package/src/validate.ts CHANGED
@@ -59,6 +59,12 @@ import {
59
59
  type DeformReach,
60
60
  type DialSpan,
61
61
  } from './deformmeasure.ts';
62
+ import {
63
+ LEGACY_BONE_INHERIT_KEY,
64
+ spineGeneration,
65
+ TOPLEVEL_CONSTRAINT_ARRAYS,
66
+ type SpineGeneration,
67
+ } from './generation.ts';
62
68
  import { colourTypeName, readPngInfo } from './png.ts';
63
69
  import {
64
70
  CHANNELS_BY_KIND,
@@ -188,6 +194,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
188
194
  A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
189
195
  A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
190
196
  A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
197
+ A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
191
198
  };
192
199
 
193
200
  /**
@@ -291,6 +298,7 @@ export const SKIP_NO_ATTACHMENT_REGION_JOIN =
291
298
  'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
292
299
  export const SKIP_NO_TWO_COLOR_TINT =
293
300
  'no slot declares a "dark" colour and no animation keys an "rgba2" timeline, so there is no two-colour tint to read back';
301
+ export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
294
302
  /**
295
303
  * A09's, which predates this list and joins it rather than being rewritten: it
296
304
  * is the same fact about the same subject, and a control that compares against
@@ -512,11 +520,20 @@ function float32Step(t: number): number {
512
520
  }
513
521
 
514
522
  /**
515
- * `4.3`, `4.3.<patch>`, or `4.3.<patch>-<suffix>` — the last of which is what the
516
- * Spine editor writes for a pre-release (`"4.3.75-beta"` in all twelve official
517
- * example exports). The major/minor pair is the load-bearing part; see A16.
523
+ * The generation `A16` demands, which is the whole of what that assertion is
524
+ * about: the MAJOR.MINOR pair.
525
+ *
526
+ * ⭐ **The reading itself moved to [`generation.ts`](generation.ts)** with issue
527
+ * #706 — one reader of `skeleton.spine` for the whole repository, because
528
+ * `ingest` has to ask the same question of a file somebody else wrote and two
529
+ * regexes would answer it two ways. What did NOT move is the accepted set:
530
+ * `4.3`, `4.3.<patch>` and `4.3.<patch>-<suffix>`, the last of which is what the
531
+ * editor writes for a pre-release (`"4.3.75-beta"` in all twelve official
532
+ * example exports, and the string the original `/^4\.3(\.\d+)?$/` rejected —
533
+ * blocker B2). `GN05` holds that set against the old regex, which survives in
534
+ * `selftest.ts` and nowhere else, for exactly that comparison.
518
535
  */
519
- const SPINE_4_3_VERSION = /^4\.3(\.\d+(-[0-9A-Za-z][0-9A-Za-z.+-]*)?)?$/;
536
+ const SPINE_4_3: SpineGeneration = '4.3';
520
537
 
521
538
  type Json = Record<string, unknown>;
522
539
 
@@ -616,9 +633,54 @@ export function attachmentRegionJoins(raw: unknown): AttachmentRegionJoin[] {
616
633
  return joins;
617
634
  }
618
635
 
636
+ /**
637
+ * The mesh keys the `source` branch never reaches — the geometry a linked mesh
638
+ * may state and nothing reads (issue #710).
639
+ *
640
+ * Derived from the branch rather than chosen. `readAttachment` returns at
641
+ * `SkeletonJson.js:586` as soon as `source` is truthy, and everything below that
642
+ * return reads `map.uvs` (twice: as the length handed to `readVertices` and as
643
+ * `regionUVs`), `map.triangles`, `map.edges` and `map.hull`. `vertices` is on the
644
+ * list because `readVertices` reads `map.vertices` and nothing else (`:654`), so
645
+ * it goes unread with the call that would have read it.
646
+ *
647
+ * ⚠️ `width` and `height` are NOT on this list, although `setSourceMesh`
648
+ * overwrites both with the source's (`MeshAttachment.js:102-103`). The branch
649
+ * reads them at `:569-570`, the format carries them on a link and rigc emits
650
+ * them (#691). A key the parser reads is not a key the parser ignores, whatever
651
+ * a later pass does with the value.
652
+ */
653
+ const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'] as const;
654
+
655
+ /** One linked mesh as the FILE spells it, before the loader has resolved anything. */
656
+ interface RawLinkedMesh {
657
+ /** The placeholder of the mesh whose geometry this attachment draws. */
658
+ source: string;
659
+ /**
660
+ * The `skin` the entry states, or `undefined` for the parser's default — which
661
+ * is the DEFAULT skin and never the skin the link is written in (`:429`).
662
+ */
663
+ skin?: string;
664
+ /** The `slot` the entry states, or `undefined` for the parser's default: this attachment's own slot (`:573-579`). */
665
+ slot?: string;
666
+ /** Which of `LINKED_MESH_UNREAD_KEYS` this entry states, in that order. */
667
+ geometry: string[];
668
+ /**
669
+ * How big a mesh those keys describe — `uvs.length / 2` and
670
+ * `triangles.length / 3` — when the entry states them as arrays.
671
+ *
672
+ * Read so that the failure can put the shape the author wrote beside the shape
673
+ * the runtime draws. `undefined` where the file states the key as something
674
+ * other than an array, which is a file this rule refuses for the key rather
675
+ * than for its length.
676
+ */
677
+ statedVertices?: number;
678
+ statedTriangles?: number;
679
+ }
680
+
619
681
  /**
620
682
  * `"<skin>\0<slot>\0<placeholder>" -> source` for every linked mesh the raw
621
- * skeleton declares (issue #691).
683
+ * skeleton declares, with what the file says about each one (issues #691, #710).
622
684
  *
623
685
  * 🔑 The test is the parser's own and it is not `type`: `type: "mesh"` and
624
686
  * `type: "linkedmesh"` share one branch and a truthy `source` is what decides
@@ -626,8 +688,8 @@ export function attachmentRegionJoins(raw: unknown): AttachmentRegionJoin[] {
626
688
  * there, so it is not a link here either — that map is read as an ordinary mesh,
627
689
  * which is exactly what the runtime does with it.
628
690
  */
629
- function rawLinkedMeshSources(raw: unknown): Map<string, string> {
630
- const links = new Map<string, string>();
691
+ function rawLinkedMeshes(raw: unknown): Map<string, RawLinkedMesh> {
692
+ const links = new Map<string, RawLinkedMesh>();
631
693
  if (!isObj(raw) || !Array.isArray(raw.skins)) return links;
632
694
  for (const skin of raw.skins as unknown[]) {
633
695
  if (!isObj(skin) || !isObj(skin.attachments)) continue;
@@ -640,7 +702,14 @@ function rawLinkedMeshSources(raw: unknown): Map<string, string> {
640
702
  if (type !== 'mesh' && type !== 'linkedmesh') continue;
641
703
  const source = entry.source;
642
704
  if (typeof source !== 'string' || source.length === 0) continue;
643
- links.set(`${skinName}\u0000${slot}\u0000${placeholder}`, source);
705
+ links.set(`${skinName}\u0000${slot}\u0000${placeholder}`, {
706
+ source,
707
+ skin: typeof entry.skin === 'string' ? entry.skin : undefined,
708
+ slot: typeof entry.slot === 'string' ? entry.slot : undefined,
709
+ geometry: LINKED_MESH_UNREAD_KEYS.filter((key) => entry[key] !== undefined),
710
+ statedVertices: Array.isArray(entry.uvs) ? entry.uvs.length / 2 : undefined,
711
+ statedTriangles: Array.isArray(entry.triangles) ? entry.triangles.length / 3 : undefined,
712
+ });
644
713
  }
645
714
  }
646
715
  }
@@ -1585,7 +1654,7 @@ export function validate(input: ValidateInput): ValidateReport {
1585
1654
  // stay rejected.
1586
1655
  check('A16_SKELETON_VERSION_4_3', () => {
1587
1656
  const declared = isObj(raw?.skeleton) ? (raw.skeleton as Json).spine : undefined;
1588
- if (typeof declared !== 'string' || !SPINE_4_3_VERSION.test(declared)) {
1657
+ if (typeof declared !== 'string' || spineGeneration(declared) !== SPINE_4_3) {
1589
1658
  fail(
1590
1659
  'A16_SKELETON_VERSION_4_3',
1591
1660
  `skeleton.spine is ${JSON.stringify(declared)}, expected 4.3, 4.3.<patch> or 4.3.<patch>-<suffix>`,
@@ -1596,9 +1665,11 @@ export function validate(input: ValidateInput): ValidateReport {
1596
1665
  // --- A01: no legacy top-level constraint arrays ---------------------------
1597
1666
  // 4.3 folds every constraint into one `constraints` array with a `type`.
1598
1667
  // A 4.1/4.2-shaped `physics` array loads clean and the constraint just
1599
- // vanishes.
1668
+ // vanishes. ⭐ The list is `generation.ts`'s since #706, because `ingest` has
1669
+ // to count the same arrays in a file it did not emit, and two copies of five
1670
+ // names is how one of them comes to be four.
1600
1671
  check('A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', () => {
1601
- for (const key of ['ik', 'transform', 'path', 'physics', 'slider']) {
1672
+ for (const key of TOPLEVEL_CONSTRAINT_ARRAYS) {
1602
1673
  if (raw && key in raw) {
1603
1674
  fail(
1604
1675
  'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS',
@@ -1610,11 +1681,12 @@ export function validate(input: ValidateInput): ValidateReport {
1610
1681
 
1611
1682
  // --- A02: no bone.transform key ------------------------------------------
1612
1683
  // 4.3 renamed it to `inherit`; the old key loads and silently falls back to
1613
- // Normal inheritance (case 6b).
1684
+ // Normal inheritance (case 6b). The key itself is `generation.ts`'s, for
1685
+ // A01's reason.
1614
1686
  check('A02_NO_BONE_TRANSFORM_KEY', () => {
1615
1687
  const bones = Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : [];
1616
1688
  for (const bone of bones) {
1617
- if (isObj(bone) && 'transform' in bone) {
1689
+ if (isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone) {
1618
1690
  fail('A02_NO_BONE_TRANSFORM_KEY', `bone "${String(bone.name)}" uses 4.2's "transform"; 4.3 wants "inherit"`);
1619
1691
  }
1620
1692
  }
@@ -1689,8 +1761,8 @@ export function validate(input: ValidateInput): ValidateReport {
1689
1761
  let clippingCount = 0;
1690
1762
  const meshSlots = new Set<number>();
1691
1763
  /**
1692
- * The loaded mesh of every attachment the FILE spells as a link, to the
1693
- * `source` it names (issue #691).
1764
+ * The loaded mesh of every attachment the FILE spells as a link, to what the
1765
+ * file says about it (issues #691, #710).
1694
1766
  *
1695
1767
  * 🔑 Read off the raw JSON and joined by (skin, slot, placeholder) rather than
1696
1768
  * asked of the loaded object, because `MeshAttachment.sourceMesh` is **private
@@ -1703,11 +1775,23 @@ export function validate(input: ValidateInput): ValidateReport {
1703
1775
  * placeholder is unique only within one skin's slot and several skins fill
1704
1776
  * one — and every assertion downstream holds the attachment, not its address.
1705
1777
  */
1706
- const linkedMeshes = new Map<MeshAttachment, string>();
1778
+ const linkedMeshes = new Map<MeshAttachment, RawLinkedMesh>();
1779
+ /**
1780
+ * The same pairing the other way round — join key -> the attachment the loader
1781
+ * produced for it — which is what `A44` needs and `kindOf` does not.
1782
+ *
1783
+ * 🔑 Two maps rather than one because the two questions are different. Every
1784
+ * rule that asks "is THIS attachment a link" holds the object and wants the
1785
+ * file's word about it; `A44` walks the FILE's links and asks what the runtime
1786
+ * made of each, including the answer "nothing" — a link whose region is
1787
+ * missing loads as `null` and is in no skin at all (`A08` names that), so its
1788
+ * join key is absent here while the file still declares it.
1789
+ */
1790
+ const loadedLinks = new Map<string, MeshAttachment>();
1707
1791
 
1708
1792
  if (skeletonData) {
1709
1793
  const data = skeletonData as NonNullable<typeof skeletonData>;
1710
- const rawLinks = rawLinkedMeshSources(raw);
1794
+ const rawLinks = rawLinkedMeshes(raw);
1711
1795
  for (const skin of data.skins) {
1712
1796
  for (const entry of skin.getAttachments()) {
1713
1797
  const att = entry.attachment;
@@ -1715,8 +1799,12 @@ export function validate(input: ValidateInput): ValidateReport {
1715
1799
  else if (att instanceof MeshAttachment) {
1716
1800
  meshAttachments.push(att);
1717
1801
  meshSlots.add(entry.slotIndex);
1718
- const source = rawLinks.get(`${skin.name}\u0000${data.slots[entry.slotIndex].name}\u0000${entry.placeholder}`);
1719
- if (source !== undefined) linkedMeshes.set(att, source);
1802
+ const join = `${skin.name}\u0000${data.slots[entry.slotIndex].name}\u0000${entry.placeholder}`;
1803
+ const link = rawLinks.get(join);
1804
+ if (link !== undefined) {
1805
+ linkedMeshes.set(att, link);
1806
+ loadedLinks.set(join, att);
1807
+ }
1720
1808
  } else if (att instanceof ClippingAttachment) clippingCount++;
1721
1809
  }
1722
1810
  }
@@ -2097,7 +2185,7 @@ export function validate(input: ValidateInput): ValidateReport {
2097
2185
  list
2098
2186
  .filter((m) => kindOf(m) === 'authored')
2099
2187
  .map((m) => {
2100
- const source = linkedMeshes.get(m);
2188
+ const source = linkedMeshes.get(m)?.source;
2101
2189
  return source === undefined ? `"${m.name}"` : `"${m.name}" (linked to "${source}")`;
2102
2190
  });
2103
2191
 
@@ -3859,6 +3947,70 @@ export function validate(input: ValidateInput): ValidateReport {
3859
3947
  }
3860
3948
  }
3861
3949
  });
3950
+
3951
+ // --- A44: a linked mesh states no geometry of its own ------------------
3952
+ //
3953
+ // 🚨 The one shape the parser reads in SILENCE. `readAttachment` returns from
3954
+ // the `source` branch at `SkeletonJson.js:586`, before `map.uvs` is touched
3955
+ // at all, so `uvs`, `triangles`, `vertices`, `hull` and `edges` written on a
3956
+ // link are read by nothing — and `setSourceMesh` then fills the attachment
3957
+ // with the SOURCE's arrays. The file says one mesh and every runtime draws
3958
+ // another, which is why this is `validity` and not one renderer's policy.
3959
+ //
3960
+ // ⭐ **It is a separate assertion rather than a clause on A04, and the reason
3961
+ // is measurable both ways.** A04 reads the LOADED attachment —
3962
+ // `mesh.triangles`, `mesh.worldVerticesLength`, `mesh.vertices` — which on a
3963
+ // link are the source's after `setSourceMesh`: measured on a forged link
3964
+ // declaring 5 uvs and 3 triangles beside a 4-vertex source, A04 PASSED
3965
+ // having read 8 and 2, the source's own. The keys this rule is about are not
3966
+ // in the data A04 holds, so the clause would have had to reach for the raw
3967
+ // file, and a verdict line reading A04's name would then be naming a
3968
+ // measurement of the loaded geometry while deciding about file keys nothing
3969
+ // read. The SKIP is the sharper half: A04's subject is mesh attachments, so
3970
+ // on a rig with meshes and no link its subject is PRESENT and it passes —
3971
+ // there is no verdict left for "this rig has no link to measure", and a
3972
+ // clause that cannot report SKIP reports a pass for an absent subject, which
3973
+ // this repository already has a judgment about.
3974
+ //
3975
+ // ⚠️ The subject is the FILE's links and not the loaded ones. A link whose
3976
+ // region is missing loads as `null` and is in no skin (`A08` names it), so
3977
+ // walking the loaded attachments would let the whole rule vanish on exactly
3978
+ // the file that is already wrong.
3979
+ check('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', () => {
3980
+ if (rawLinks.size === 0) return skip('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', SKIP_NO_LINKED_MESH);
3981
+ for (const [join, link] of rawLinks) {
3982
+ if (link.geometry.length === 0) continue;
3983
+ const [skinName, slotName, placeholder] = join.split('\u0000');
3984
+ const at = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} placeholder ${JSON.stringify(placeholder)}`;
3985
+ const keys = link.geometry.map((key) => JSON.stringify(key)).join(', ');
3986
+ // Where the parser looks for `source`, with the two defaults spelled out:
3987
+ // an omitted `skin` is the DEFAULT skin rather than this attachment's own
3988
+ // (`:429`), and an omitted `slot` IS this attachment's own (`:573-579`).
3989
+ const where =
3990
+ `skin ${JSON.stringify(link.skin ?? 'default')}${link.skin === undefined ? ' (the default skin, because no "skin" was stated — never the skin this attachment is written in)' : ''} ` +
3991
+ `slot ${JSON.stringify(link.slot ?? slotName)}${link.slot === undefined ? ' (this attachment\'s own, because no "slot" was stated)' : ''}`;
3992
+ // What the author's own keys describe, printed only when both are
3993
+ // readable — the shape the file states, beside the shape it draws.
3994
+ const states =
3995
+ link.statedVertices === undefined || link.statedTriangles === undefined
3996
+ ? ''
3997
+ : ` (${link.statedVertices} vertices and ${link.statedTriangles} triangles)`;
3998
+ const drawn = loadedLinks.get(join);
3999
+ const loaded =
4000
+ drawn === undefined
4001
+ ? 'what it loaded is not shown here because the round trip produced no attachment for it (A00 owns that)'
4002
+ : `it loaded ${drawn.worldVerticesLength / 2} vertices and ${drawn.triangles.length / 3} triangles`;
4003
+ fail(
4004
+ 'A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN',
4005
+ `${at} links to ${JSON.stringify(link.source)} and states ${keys}${states}, and a linked mesh has no geometry of ` +
4006
+ 'its own. The parser returns from the `source` branch before `readVertices` ' +
4007
+ `(\`SkeletonJson.ts:582-586\`), so ${link.geometry.length === 1 ? 'that key is' : 'those keys are'} read by ` +
4008
+ `nothing at all: what this attachment draws is the geometry of ${JSON.stringify(link.source)} in ${where}, and ` +
4009
+ `${loaded}. Remove ${link.geometry.length === 1 ? 'it' : 'them'}, or remove "source" and author this as a ` +
4010
+ 'mesh of its own.',
4011
+ );
4012
+ }
4013
+ });
3862
4014
  }
3863
4015
 
3864
4016
  // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
@@ -4114,14 +4266,39 @@ export function validate(input: ValidateInput): ValidateReport {
4114
4266
  // `rotate: 270` and red at 0, 90 and 180 — this assertion's own verdict,
4115
4267
  // flipped by the rotation it does not judge (issue #579). The footprint
4116
4268
  // is `pageFootprint`'s, which every other reader of it now calls.
4269
+ // 🚨 **The scan counts what it READ, and zero texels read is not a
4270
+ // verdict** (issue #705). The `continue` above walks past every
4271
+ // coordinate that is not on the page, so a rectangle none of whose
4272
+ // texels are on it came out of this loop with `transparent` still
4273
+ // false — indistinguishable from a solid drawing — and the sentence
4274
+ // below then stated opacity over texels nobody had opened. Measured on
4275
+ // a pack shaped like #707's: `part "block" is opaque in every one of
4276
+ // its 12x8 texels`, over **0 of 96**, on art carrying 36 clear texels
4277
+ // where it was packed. That is the message-as-UI defect in one line —
4278
+ // the reader is sent to re-export a part whose alpha was never the
4279
+ // problem, and the rectangle that is the problem belongs to A06.
4280
+ //
4281
+ // ⚠️ It is a FAIL rather than a SKIP, and the report's own shape
4282
+ // decides that rather than taste. `skip()` is per ASSERTION, so
4283
+ // skipping here would delete the verdicts on every other part of the
4284
+ // page — on that same pack the second part is genuinely opaque and is
4285
+ // named — and adding a skip BESIDE those failures puts A19 in two of
4286
+ // the four buckets `reportLines` adds up, which prints `45 assertions`
4287
+ // where the registry holds 44. What is left is a failure that says
4288
+ // what was not measured, which is also what "green means measured"
4289
+ // requires: a part this rule could not read must not be certified by
4290
+ // it.
4117
4291
  const plate = readPlate(abs);
4118
4292
  for (const region of on) {
4119
4293
  if (baseRegions.has(region.name)) continue;
4120
4294
  const { width, height } = pageFootprint(region);
4295
+ const declared = width * height;
4296
+ let read = 0;
4121
4297
  let transparent = false;
4122
4298
  for (let y = region.y; y < region.y + height && !transparent; y++) {
4123
4299
  for (let x = region.x; x < region.x + width; x++) {
4124
4300
  if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) continue;
4301
+ read++;
4125
4302
  if (plate.get(x, y)[3] < 255) {
4126
4303
  transparent = true;
4127
4304
  break;
@@ -4129,12 +4306,37 @@ export function validate(input: ValidateInput): ValidateReport {
4129
4306
  }
4130
4307
  }
4131
4308
  if (transparent) continue;
4309
+ // The page's size here is the DECODED image's and not the `size:`
4310
+ // line's, because it is the bound this scan actually clipped
4311
+ // against; where the two disagree A06 says so in its own sentence.
4312
+ if (read === 0) {
4313
+ fail(
4314
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
4315
+ `part "${region.name}" is not measured: this rule read 0 of the ${declared} texels of its ` +
4316
+ `${width}x${height} rectangle at ${region.x},${region.y} on page "${page.name}", whose image is ` +
4317
+ `${plate.width}x${plate.height}, so it states nothing about whether "${region.name}" can draw a ` +
4318
+ "transparent pixel. A region's rectangle is A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and one " +
4319
+ 'that runs off its page is refused there by name. This is renderer policy, and it belongs to ' +
4320
+ '--profile spine-html: the default --profile spine does not run this check.',
4321
+ );
4322
+ continue;
4323
+ }
4324
+ // A rectangle partly on the page states the verdict over the texels
4325
+ // it read and says how many of the declared ones that was. A whole
4326
+ // rectangle prints the sentence it has always printed, to the byte.
4327
+ const over =
4328
+ read === declared
4329
+ ? `every one of its ${width}x${height} texels on shared page "${page.name}"`
4330
+ : `every one of the ${read} texels of its ${width}x${height} rectangle at ${region.x},${region.y} ` +
4331
+ `that are on shared page "${page.name}", whose image is ${plate.width}x${plate.height} — the ` +
4332
+ `other ${declared - read} of the ${declared} it declares are not on the page and are not ` +
4333
+ 'measured here';
4132
4334
  fail(
4133
4335
  'A19_OVERLAY_PNGS_HAVE_ALPHA',
4134
- `part "${region.name}" is opaque in every one of its ${width}x${height} texels on shared page ` +
4135
- `"${page.name}", so it would paint a solid rectangle over whatever is drawn behind it. Re-export ` +
4136
- `the part with transparency and pack again. ${exemption} This is renderer policy, and it belongs ` +
4137
- 'to --profile spine-html: the default --profile spine does not run this check.',
4336
+ `part "${region.name}" is opaque in ${over}, so it would paint a solid rectangle over whatever is ` +
4337
+ `drawn behind it. Re-export the part with transparency and pack again. ${exemption} This is ` +
4338
+ 'renderer policy, and it belongs to --profile spine-html: the default --profile spine does not run ' +
4339
+ 'this check.',
4138
4340
  );
4139
4341
  }
4140
4342
  continue;