spine-rigc 1.0.0 → 1.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.
@@ -1,226 +1,20 @@
1
- # Spine 4.3 export-format surface vs. rigc coverage vs. the example ladder
2
-
3
- **A dated record:** what this page states was measured on the date it carries and is not kept current, so the selftest's currency gate leaves its figures alone.
4
-
5
- Research note, 2026-08-22. A survey, not a plan: it establishes what the 4.3 export format can hold,
6
- what rigc emits today, and what the official example projects actually use — so the gap list is
7
- ordered by what the benchmark ladder needs rather than by the spec's alphabet.
8
-
9
- Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
10
- ([`bench/count_features.ts`](https://github.com/firejune/rigc/blob/main/bench/count_features.ts) → [`feature_matrix.csv`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.csv),
11
- [`feature_matrix.json`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.json)).
12
-
13
- > 🔼 **This note is dated and does not move; the measurements below are the corpus as it
14
- > stood on 2026-08-22 and stay as written. Its statements about what rigc *does* have gone
15
- > stale in the places listed here, and the live ledger is [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md):**
16
- >
17
- > - **B2 is fixed** — A16 accepts `4.3`, `4.3.N` and `4.3.N-<suffix>`, so all twelve example
18
- > exports pass it.
19
- > - **The profile split exists** — `--profile spine | spine-html`, **default `spine` since
20
- > 2026-08-29** (issue #221; it was `spine-html` when this survey was written), so the nine
21
- > renderer-policy assertions of §2.2 no longer apply to foreign data, and no longer apply
22
- > unasked. That closes the validator half of B3; **the emitter half closed on 2026-09-03
23
- > (issue #4)** — `build --pack` arranges the parts onto shared power-of-two pages written into
24
- > `--out`, and `build --atlas-in <file.atlas>` resolves every part against the regions of a
25
- > pack somebody else made. Both are opt-in and both are narrow: no trimming, no rotation
26
- > (`rotate: 0` on every region rigc writes), no scaling, and a packed atlas is refused under
27
- > `--profile spine-html`, whose A06 clause **is** the one-part-per-page convention. So §3-2's
28
- > atlas table, §4-0's B3 row and its "no packer *and* no importer" both describe a state that
29
- > no longer exists; what remains true of them is the DEFAULT, which is still one part per page.
30
- > Limits and flag shapes: [AUTHORING §0.1–§0.2](https://github.com/firejune/rigc/blob/main/docs/AUTHORING.md).
31
- > - **Part 4-2's ❌ rows for `path`, `slider` and per-skin members are gone (issues #2, #7)** —
32
- > rigc emits path attachments and path constraints, sliders in both of their models, per-skin
33
- > `bones` and constraint lists, and the `path` / `slider` timeline groups. Three assertions came
34
- > with them (A36, A37, A38). The **corpus** measurements in part 3 are untouched and still true:
35
- > none of these features appears in any of the twelve example files, which is why they were
36
- > off the ladder and why nothing in it moved.
37
- > - **`walkTimelines` reaches all eleven groups** — §2.3 item 1 and §4.3 item 4 are done, so
38
- > A05 is no longer blind to `ik` / `transform` / `path` / `slider` / `drawOrder` /
39
- > `drawOrderFolder` / `events`.
40
- > - **B1 is closed (2026-08-22)** — the bone tree is no longer code. `src/archetype.ts` is gone
41
- > and the skeleton is a **rig spec** (`spec: "rigc-rig/1"`, `src/rig.ts`): bones, slots, skins,
42
- > attachments, the 4.3 typed constraint array and the invariants, all as data. So §2.4's
43
- > "the bone tree is code, not data", §4.0's B1 row and executive-summary item 7 describe a
44
- > state that no longer exists. Rung 3 transcribed into that format scores 1.000 on every
45
- > measure of every section — figures in [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
46
- > - Since that change rigc also emits **ik and transform constraints**, the **single-axis bone
47
- > timelines** (`translatex`/`translatey`/`scalex`/`scaley`/`shear`/`shearx`/`sheary`), bone
48
- > `length`/`scale`/`shear`/`inherit`/`skin`/`color` setup fields, slot `dark`/`blend`, region
49
- > `path`/`scaleX`/`scaleY`/`color`, mesh `path`/`edges`/`color`, and the header's `fps` /
50
- > `referenceScale` / `images`. Part 2's coverage tables predate all of it.
51
- > - **A08's name-identity clause is retired (issue #574, 2026-09-17)** — so every line below
52
- > that calls A08 *mixed* or names it as policy describes a rule that no longer exists:
53
- > §2.1's `region` row ("the region name == attachment name == PNG basename convention
54
- > **A08** + **A27** enforce" — A27 still enforces its half, A08 enforces none of it),
55
- > §2.2's classification row and its "Nine assertions are renderer-profile or
56
- > profile-mixed" paragraph, §4.1's row (c), and §4.3's item 2. A08 is plain **validity**
57
- > now and identical under both profiles. The measurement behind it: `spine-html` resolves
58
- > art through the attachment's `path` and keys its images on the atlas region name, in
59
- > every published version of it, so the clause was a rule no renderer performed — and it
60
- > fired on **0** attachments in this corpus, because no export in `examples/` carries a
61
- > `path` field at all. The executive summary's item 8 is the one A08 line here that was
62
- > already right and has only got more so: A08 is a Spine-validity rule.
63
- > - **The executive summary's item 6 is superseded where it lists what rigc does
64
- > not emit** — *"No IK, no transform constraint, no path, no slider, no deform,
65
- > no drawOrder, no events"*. The first four are covered by the bullets above;
66
- > the last three emit too, and each has an assertion over it
67
- > (`A31_DRAW_ORDER_OFFSETS_RESOLVE`, `A32_EVENT_KEYS_RESOLVE`,
68
- > `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT`). What survives of that item is that the
69
- > emitter covers a slice of the format, not which slice it covers.
70
- > - **Part 4's *"Three of its items have been built since"* undercounts** — the
71
- > bullets above already name more than three between them, and most of the
72
- > requirement list under it has landed. Read it as *some*, and take which from
73
- > these bullets and [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md);
74
- > the requirement text itself is still left exactly as surveyed, which is what
75
- > a dated record is for.
76
- > - **"Things I could not verify" item 5 — whether the Spine editor itself would
77
- > round-trip a rigc file — has been answered by measurement since.**
78
- > `tools/editor_roundtrip.ts` ships inside the package, the registry carries
79
- > `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP`, and [AUTHORING.md](https://github.com/firejune/rigc/blob/main/docs/AUTHORING.md)
80
- > records the trips by number. *"The round-trip proof in this project is
81
- > spine-core-only"* was true on the date above and is not the state now.
82
- >
83
- > **The measurements of the CORPUS (Part 3) have not moved and stay as written.**
84
- >
85
- > 🔒 **Redacted for publication (2026-08-23), and nothing but names.** The note was written
86
- > inside the sandbox of the project rigc was split out of, and it enumerated that project's
87
- > archetype names, bone tree and slot table. Those are the consumer's, not this
88
- > repository's, so they are replaced by the SHAPE they stood for — counts, parentage,
89
- > structure — which is what every argument here actually rests on: §2.4's point is that
90
- > eighteen bones were hard-coded and *no example fits them*, and that survives the names
91
- > going. Where a number was measured it is still the measured number.
92
-
93
- ---
94
-
95
- ## Executive summary (10 lines)
96
-
97
- 1. **The de facto 4.3 spec is `SkeletonJson.ts`, and it has no schema** — every field passes through
98
- `getValue(map, key, default)` (`SkeletonJson.ts:1404-1406`), so anything unknown is ignored and
99
- anything missing silently becomes a default. There is no version gate in the JSON path.
100
- 2. **The official JSON doc is still 3.8.24** (verified today, one version token on the page) and it
101
- documents a format shape that 4.3 *no longer reads*: top-level `"ik"`/`"transform"`/`"path"`
102
- arrays, bone `"transform"`, `"draworder"` all-lowercase. Where doc and parser disagree, **the
103
- parser wins**; writing to the doc produces a file that loads clean and is wrong.
104
- 3. **The atlas and binary docs, by contrast, are current 4.x** — the atlas page documents
105
- `bounds:`/`offsets:`/`pma:`, which is the compact form the 4.3 reader prefers.
106
- 4. **4.3 is the biggest format break of the 4.x line**, and I verified the transition points against
107
- the 4.0/4.1/4.2 parsers: 4.1 added `sequence`, 4.2 added `physics` + `referenceScale` and renamed
108
- bone `transform`→`inherit`, 4.3 collapsed all constraints into one `constraints[]` array with a
109
- `type` discriminator, added the `slider` constraint, `drawOrderFolder`, bone icons, and rebuilt
110
- the transform constraint around `source` + `properties`.
111
- 5. The full 4.3 JSON surface is **7 attachment types, 5 constraint types, 37 timeline kinds,
112
- 3 curve encodings** — enumerated with line numbers in Part 1.
113
- 6. **rigc emits a narrow slice of it**: 2 of 7 attachment types (region, weighted mesh), 1 of 5
114
- constraint types (physics), and **7 of 37 timeline kinds** (`rotate`/`translate`/`scale` on bones,
115
- `attachment`/`rgba` on slots, `mix`/`reset` on physics). One skin, named `default`. No IK, no
116
- transform constraint, no path, no slider, no deform, no drawOrder, no events, no sequences.
117
- 7. rigc's **input model is narrower than its emitter**: the bone tree is *code*
118
- (`src/archetype.ts`, three hard-coded archetypes), not data. That is the single largest blocker
119
- for the example ladder — **no example project fits any of the three.**
120
- 8. rigc's 31 assertions split into two kinds that must be separated before the ladder:
121
- **Spine-validity rules** (A00–A05, A08, A17, A27 — these catch real silent corruption) and
122
- **renderer/canvas-profile rules for spine-html** (A06 no-rotate/no-PMA, A11 no clipping, A12 no
123
- dark colour, A13 ≤4 mesh slots ≤80 tris, A14 no full-frame mesh, A19 alpha, A20 meshes must be
124
- weighted). The second group is a *policy*, not a spec, and must become a switch.
125
- 9. The validator is **structurally blind** to whole timeline groups: `walkTimelines`
126
- (`src/validate.ts:1159-1199`) only descends `bones`/`slots`/`physics`/`attachments`. An `ik`,
127
- `transform`, `path`, `slider`, `drawOrder`, or `events` block would pass A05 unexamined.
128
- 10. **The ladder uses far less of the format than the format holds** (12 skeleton files measured,
129
- all `"4.3.75-beta"`, all flat-`constraints` shape): **no example uses a non-default skin**, path
130
- constraint, slider, sequence, linked mesh, or dark colour. First appearances that matter:
131
- drawOrder at rung 5, transform constraints + weighted meshes at rung 6, physics + deform at
132
- rung 7, IK + clipping + events + bounding box + unweighted meshes only at spineboy. Three
133
- blockers precede rung 1 — the bone tree being code, `A16`'s regex rejecting `4.3.75-beta`, and
134
- the one-part-per-page atlas model against nine packed atlases. **Nothing was implemented *at the
135
- time of writing*; see the banner above for what has been since.**
136
-
137
- **Prior work this document extends, not repeats:** an earlier round of design notes covering parse
138
- coverage (including the finding that the official JSON documentation is still 3.8), the round-trip
139
- probe and the **six silent-failure cases** it measured — referred to below as cases 6a–6h — and a
140
- coverage matrix of what one editor export actually contained. Those notes are design records held
141
- by the project rigc was split out of, not files in this repository; every finding this document
142
- leans on is restated here rather than cited. The wider question they did not ask is the one below:
143
- what can the format hold at all, and what does the public benchmark corpus actually use.
144
-
145
- ---
146
-
147
- ## Sources
148
-
149
- | # | Source | Lines | Version |
150
- | --- | --- | ---: | --- |
151
- | S1 | [`spine-core/src/SkeletonJson.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/SkeletonJson.ts) | 1406 | matches the pinned `@esotericsoftware/spine-core@4.3.13` (case-label diff: identical) |
152
- | S2 | [`spine-core/src/SkeletonBinary.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/SkeletonBinary.ts) | 1455 | idem |
153
- | S3 | [`spine-core/src/TextureAtlas.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/TextureAtlas.ts) | 274 | idem |
154
- | S4 | [`spine-core/src/Animation.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/Animation.ts) | 2560 | idem |
155
- | S5 | `spine-core/src/` — `BoneData.ts`, `SlotData.ts`, `ConstraintData.ts`, `PathConstraintData.ts`, `attachments/Sequence.ts`, `Skin.ts`, `SkeletonData.ts`, `Utils.ts` | — | idem |
156
- | S6 | `SkeletonJson.ts` on branches `4.0`, `4.1`, `4.2` | — | for the format-diff table below |
157
- | S7 | https://esotericsoftware.com/spine-json-format | — | **3.8.24** |
158
- | S8 | https://esotericsoftware.com/spine-binary-format | — | no version token on page |
159
- | S9 | https://esotericsoftware.com/spine-atlas-format | — | no version token; content is 4.x |
160
- | S10 | https://esotericsoftware.com/spine-loading-skeleton-data | — | — |
161
- | S11 | [`spine-runtimes/CHANGELOG.md`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/CHANGELOG.md) | 3164 | — |
162
- | S12 | [`spine-runtimes/README.md`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/README.md) | — | — |
163
- | S13 | this repository | — | v0.1.0; `src/*.ts`, `cli.ts`, `selftest.ts`, `README.md`, `NOTICE.md` |
164
- | S14 | the nine example projects under `examples/` (`bun run fetch-examples`) | — | all export `"spine": "4.3.75-beta"` |
165
-
166
- **Citation convention.** Bare `:NNN` line numbers are into the branch-`4.3` TypeScript sources above,
167
- which are also what `node_modules/@esotericsoftware/spine-core/src/<File>.ts` would hold —
168
- ⚠️ the published npm package ships **`dist/` only**, so the local copy to read is
169
- `node_modules/@esotericsoftware/spine-core/dist/<File>.js`, whose line numbers differ. The two were
170
- reconciled before publication: the parser's full `case` label set is identical in both.
171
-
172
- **Marker convention used in Part 1:** 📘 = the field is described on an official Esoteric docs page.
173
- 🔍 = source-only; the parser reads it but no public doc page describes it.
174
-
175
- ### Verified doc-status findings
176
-
177
- - **The JSON format page documents 3.8.24.** The only version token anywhere in the page body is
178
- `"spine" : "3.8.24"` inside its skeleton example (S7). Plan 04 §1-0 recorded this on 2026-08-21;
179
- **it is still true on 2026-08-22.** The page also still shows the 3.8-shaped
180
- `"ik" : [ { "name" : "left leg" …` top-level array and a `"draworder" : { … }` key spelled
181
- all-lowercase — the 4.3 parser reads `map.drawOrder`, camel-cased (`SkeletonJson.ts:1209`).
182
- - **The binary page carries no version token at all** and is otherwise structured like the JSON page.
183
- - **The atlas page is current.** It documents `bounds:` / `offsets:` (the 4.x compact form), `pma:`,
184
- `index:`, `split:`, `pad:`, and `rotate:` as "`true` … or a number representing degrees from 0 to
185
- 360" — which is exactly what `TextureAtlas.ts:91-97` implements. **One gap:** the atlas doc does not
186
- mention the page-level `scale:` line, yet every one of the nine official example atlases emits it
187
- (Part 3) and the spine-ts reader silently discards it. Docs and parser are both incomplete here;
188
- the *packer* is the only authority.
189
- - **The CHANGELOG is not a format ledger for 4.x.** It records per-runtime API churn. The 4.0→4.3
190
- JSON shape changes are not enumerated anywhere public; I derived them from the four parsers (S6),
191
- table below.
192
-
193
- ### On third-party tools writing these formats
194
-
195
- Esoteric's own documentation **invites** third-party tools to write these formats, in
196
- three places:
197
-
198
- - JSON format page (S7): *"Also, Spine can import data in this format, allowing interoperability with
199
- other tools."* — https://esotericsoftware.com/spine-json-format
200
- - Loading guide (S10): *"Other tools can also be used to pack a texture atlas in the Spine atlas
201
- format, such as Texture Packer Pro using the "libgdx" atlas format."* —
202
- https://esotericsoftware.com/spine-loading-skeleton-data
203
- - Esoteric's own `spine-scripts` repo README: *"In addition to exporting images, some scripts also
204
- write JSON data which can be imported into Spine."* —
205
- https://github.com/EsotericSoftware/spine-scripts
206
-
207
- What *is* stated as policy is a **version-lockstep expectation**, not a format restriction —
208
- `spine-runtimes/README.md` §Versioning (S12): *"It is highly suggested to freeze the Spine editor
209
- version to match the Spine Runtimes source being used and to update them in lock step."* Note the
210
- spine-ts **JSON** path does **not** enforce this: `SkeletonJson` stores `skeletonData.version` and
211
- never compares it (`SkeletonJson.ts:78`). Some other-language runtimes do gate on it (CHANGELOG
212
- line 1678: *"Parsing skeleton .JSON and .skel files will report an error if the skeleton version does
213
- not match the runtime version"*), so a wrong `skeleton.spine` string is portable-fragile even though
214
- spine-ts tolerates it. rigc's A16 already pins it to `4.3.x`.
215
-
216
- Separately, the licence chain matters for the ladder, and rigc's `NOTICE.md` states it as a
217
- four-point chain rather than leaving it to be inferred: rigc's output **is** Spine skeleton data,
218
- playing that data in a product needs **a Spine Runtime**, the Spine Runtimes License Agreement
219
- requires **each user of such a product to hold a Spine Editor licence**, and rigc **links
220
- spine-core** itself — so the same obligation covers running rigc at all. rigc therefore carries that
221
- obligation forward rather than routing around it, and the interoperability the pages above invite is
222
- taken on those terms. Reproducing the example projects does not change any of it; see the
223
- per-example licence findings in Part 3.
1
+ # Spine 4.3 export-format surface
2
+
3
+ This page is the Spine 4.3 skeleton JSON and atlas text surface, read off the `spine-core`
4
+ this package pins (`@esotericsoftware/spine-core@4.3.13`): every field the parser reads, what it
5
+ defaults to, and the parser line each row cites. rigc's refusals point at its rows by part
6
+ number — `part 1-6` is §1.6. It is not a statement of what rigc emits; that is
7
+ [AUTHORING.md](AUTHORING.md).
8
+
9
+ **Sources.** S1 is [`spine-core/src/SkeletonJson.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/SkeletonJson.ts),
10
+ S2 [`SkeletonBinary.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/SkeletonBinary.ts)
11
+ and S3 [`TextureAtlas.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/TextureAtlas.ts),
12
+ all on branch `4.3`; S6 is `SkeletonJson.ts` on branches `4.0`, `4.1` and `4.2`, for the
13
+ timeline below. Bare `:NNN` line numbers are into those TypeScript sources — ⚠️ the published npm
14
+ package ships **`dist/` only**, so the local copy to read is
15
+ `node_modules/@esotericsoftware/spine-core/dist/<File>.js`, whose line numbers differ; the
16
+ parser's full `case` label set is identical in both. 📘 = the field is described on an official
17
+ Esoteric docs page. 🔍 = source-only; the parser reads it but no public doc page describes it.
224
18
 
225
19
  ---
226
20
 
@@ -243,9 +37,8 @@ Evidence: `root.ik` present in 4.0/4.1/4.2 and absent in 4.3; `root.constraints`
243
37
  4.0/4.1/4.2 and present in 4.3; `uniform` at `4.2 SkeletonJson.ts:161`, gone in 4.3; `target` at
244
38
  `4.2:182,222` vs `source` at `4.3:188`.
245
39
 
246
- ⇒ **This is why case 6a matters so much for the ladder.** Any example project exported by a
247
- pre-4.3 editor carries the *legacy* shape, and the 4.3 parser drops those constraints on the floor
248
- with no error. Part 3 measures which shape each example actually ships.
40
+ ⇒ **A file exported by a pre-4.3 editor carries the *legacy* shape, and the 4.3 parser drops
41
+ those constraints on the floor with no error.**
249
42
 
250
43
  ---
251
44
 
@@ -287,7 +80,7 @@ The whole `skeleton` block is optional (`if (skeletonMap)` at `:76`).
287
80
  | `icon` | `undefined` | **4.3, editor-only affordance** | 🔍 |
288
81
  | `iconSize` | `1` | 4.3 | 🔍 |
289
82
  | `iconRotation` | `0` | 4.3 | 🔍 |
290
- | ~~`visible`~~ | — | **NOT read from JSON.** `SkeletonBinary.ts:126` reads it (nonessential); the JSON parser has no equivalent. See §1.10. | 🔍 |
83
+ | ~~`visible`~~ | — | **NOT read from JSON.** `SkeletonBinary.ts:126` reads it (nonessential); the JSON parser has no equivalent. See §1.11. | 🔍 |
291
84
 
292
85
  ### 1.3 Slots — `root.slots[]` (`:121-141`)
293
86
 
@@ -413,14 +206,13 @@ placeholder → name → path → atlas region.
413
206
  There is no flag. If `vertices.length === verticesLength` (i.e. `uvs.length`, or `vertexCount<<1`)
414
207
  it is read as **unweighted** x/y pairs; otherwise as the **weighted** run-length encoding
415
208
  `boneCount, (boneIndex, bindX, bindY, weight) × boneCount, …`. A coincidental length match reads
416
- weight data as coordinates. Plan 04 §1-3 already recorded this; it is restated here because the
417
- example corpus contains both encodings.
209
+ weight data as coordinates. Editor exports carry both encodings.
418
210
 
419
211
  🚨 The second risk in the same field is `boneIndex`: it is a position in the emitted bone array,
420
212
  so the run means something different the moment the bone list changes, and nothing in the file
421
213
  records what it used to mean. A rig spec therefore writes `weights` — the same data with the bones
422
214
  **named** — and rigc encodes this run on emit. The raw form stays reachable behind
423
- `"boneIndexing": "raw"` for transcribing an export verbatim. Issue #45.
215
+ `"boneIndexing": "raw"` for transcribing an export verbatim.
424
216
 
425
217
  ### 1.7 Events — `root.events` (object, not array) (`:469-484`)
426
218
 
@@ -500,7 +292,7 @@ rotate 4, translate 8, scale 8, shear 8, rgb 12, rgba 16, rgb2 24, rgba2 28, ik
500
292
  path mix 12, alpha/scalex/deform/physics/slider 4.
501
293
 
502
294
  **A short array is the format's nastiest silent failure**: `curve[i+3]` is `undefined`, the product
503
- is `NaN`, and nothing throws (case 6g). Timelines with no curve at all (`attachment`,
295
+ is `NaN`, and nothing throws. Timelines with no curve at all (`attachment`,
504
296
  `inherit`, `sequence`, `drawOrder`, `events`, `physics reset`) ignore a `curve` key entirely.
505
297
 
506
298
  The X axis is **seconds** in 4.x. It was frames in 3.8 — one more reason the 3.8 doc is actively
@@ -511,7 +303,7 @@ dangerous as a spec.
511
303
  Reader rules (`:194-227`): entries are `key: v1, v2, v3, v4` with **at most four values**
512
304
  (`:224`); a line with no colon terminates the current block (`:214`). A **blank line closes a page
513
305
  block** (`:119-121`). Page names are `line.trim()` (`:123`) but **region names are the raw line**
514
- (`:131`) — leading whitespace becomes part of the name. Both traps were measured in the probe.
306
+ (`:131`) — leading whitespace becomes part of the name.
515
307
 
516
308
  **Header entries before the first page are read and silently discarded** (`:106-111` — the comment
517
309
  says so literally).
@@ -523,7 +315,7 @@ says so literally).
523
315
  | `filter: min, mag` | `minFilter`/`magFilter` (`:50-53`) | 📘 |
524
316
  | `repeat: x\|y\|xy\|none` | `uWrap`/`vWrap` (`:54-57`) | 📘 |
525
317
  | `pma: true\|false` | `page.pma` (`:58-60`) | 📘 |
526
- | `scale: n` | 🚨 **emitted by the Spine texture packer, documented nowhere, and silently discarded by the reader.** There is no `pageFields.scale`, so `if (field) field(page)` at `:126-127` is a no-op. **All nine example atlases carry it** (`scale: 0.5` in eight, `scale: 0.4` in `2-the-12-principles`) — see Part 3. It records the export-time downscale factor; the runtime is expected to compensate via `SkeletonJson.scale` or `referenceScale`, not to read this line. | ❌ undocumented |
318
+ | `scale: n` | 🚨 **emitted by the Spine texture packer, documented nowhere, and silently discarded by the reader.** There is no `pageFields.scale`, so `if (field) field(page)` at `:126-127` is a no-op. It records the export-time downscale factor; the runtime is expected to compensate via `SkeletonJson.scale` or `referenceScale`, not to read this line. | ❌ undocumented |
527
319
 
528
320
  | Region field | Parsed into | Doc |
529
321
  | --- | --- | --- |
@@ -560,642 +352,6 @@ emitter:
560
352
  | Attachment colours | always read when present | only when nonessential (`:513`, `:600`, `:617`, `:630`) |
561
353
 
562
354
  ⇒ **Two things are JSON-inexpressible in 4.3 spine-ts: `bone.visible` and `skin.color`.** Both are
563
- editor-affordance data with zero rendering effect, so this costs the ladder nothing — but it is the
564
- honest answer to Part 4(d): there is no *rendering-relevant* feature that binary can express and JSON
565
- cannot.
566
-
567
- ---
568
-
569
- ## Part 2 — what rigc emits today
570
-
571
- rigc v0.1.0, pinned to `@esotericsoftware/spine-core@4.3.13`. Line references in this Part are into
572
- this repository (`src/`, `cli.ts`, `selftest.ts`) and were taken at commit `795e6f9`.
573
-
574
- Legend: ✅ emits · 🟡 partial · ❌ not emitted · 🚫 deliberately excluded by a named assertion.
575
-
576
- ### 2.1 Coverage against Part 1
577
-
578
- > 🔄 **Re-synced 2026-08-23** against `src/compile.ts`/`src/rig.ts` on `main`. Only the rows below
579
- > were touched; the rest of this table was not re-verified in this pass.
580
-
581
- **Header** (`compile.ts:634-646`)
582
-
583
- | Part-1 row | rigc | Note |
584
- | --- | --- | --- |
585
- | `spine` | ✅ | constant `SPINE_VERSION = '4.3.13'` (`compile.ts:41`); A16 re-checks it is `4.3.x` |
586
- | `x`, `y` | ✅ | always literal `0, 0` |
587
- | `width`, `height` | ✅ | from `manifest.crop.w/h` |
588
- | `hash` | ❌ | never emitted (harmless; the parser stores it and nothing reads it) |
589
- | `fps`, `referenceScale` | ✅ | copied from `rig.skeleton` when the rig spec gives them, omitted when it does not (landed in `c5eda3b`) |
590
- | `images` | ✅ | a declared `rig.skeleton.images` verbatim; otherwise the path from `--out` to the one directory the spec names every part PNG in — `--out` itself, spelled `../<basename>/`, under `--copy-images`, which overrides a declaration — so the editor's import finds them (issue #370). Omitted when the parts are spread over several directories, since no single path is true of all of them |
591
- | `audio` | ❌ | |
592
-
593
- **Bones** (`compile.ts:367-456`; type at `types.ts:285-292`)
594
-
595
- | Part-1 row | rigc |
596
- | --- | --- |
597
- | `name`, `parent`, `x`, `y` | ✅ |
598
- | `rotation` | ✅ — emitted only when non-zero; comes from `manifest.axis.deg` or a per-anchor facing angle, screen→Spine converted (`transform.ts`) |
599
- | `length`, `scaleX`, `scaleY`, `shearX`, `shearY`, `inherit`, `skin`, `color` | ✅ — copied from the rig spec when it gives them, omitted when it does not |
600
- | `icon` | ✅ — copied verbatim, unchecked; it is the editor's vocabulary, not rigc's (issue #47) |
601
- | `iconSize`, `iconRotation` | ❌ |
602
- | bone `visible` | ❌ (JSON-inexpressible anyway, §1.10) |
603
-
604
- **Slots** (`compile.ts:466-524`; type at `types.ts:294-299`)
605
-
606
- | Part-1 row | rigc |
607
- | --- | --- |
608
- | `name`, `bone` | ✅ |
609
- | `attachment` | ✅ — only when the motion spec's `setup` block names one; `null` means "show nothing" and the key is omitted |
610
- | `color` | ✅ — from `setup.color`, hex-encoded (`compile.ts:63-66`) |
611
- | `dark` | ✅ — copied from the rig spec's slot when it gives one. 🚫 **A12_NO_DARK_COLOR** still names it, and that is a **renderer-profile** verdict rather than a validity one: `build` runs `--profile spine`, where A12 reports `PROF`. **A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN** reads it back off the runtime |
612
- | `blend` | ✅ — copied from the rig spec's slot when it gives one (`compile.ts:600`, landed in `c5eda3b`) |
613
- | `visible` | ❌ |
614
- | draw order = slots array order | ✅, and **A26_SLOT_DRAW_ORDER** pins it to the archetype's `slotOrder` table |
615
-
616
- **Skins** — ✅ named skins, each `{ name, bones?, ik?, transform?, path?, physics?, slider?, attachments }`
617
- in the parser's own reading order; `default` is always emitted even when empty. ✅ per-skin `bones` and
618
- per-type constraint lists (issue #7), which a rig spec writes in a skin entry's long form and rigc pairs
619
- with the member's own `skin: true` — either half alone is refused, because `Skeleton.updateCache` starts a
620
- `skinRequired` object inactive and activates only what the applied skin names (**A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED**).
621
- ❌ `color` (JSON-inexpressible).
622
-
623
- **Attachments**
624
-
625
- | Part-1 type | rigc | Detail |
626
- | --- | --- | --- |
627
- | `region` | 🟡 | emits `width`, `height` (always, from PNG measurement — the fix for case 6c), `x`, `y` (only when non-zero), `rotation` (only when non-zero, cancelling the bone's world rotation), `scaleX`/`scaleY` (`compile.ts:1238-1239`), `color`, and `path` — taken from the rig spec, else derived from the image basename and omitted when that basename *is* the attachment name (the region name == attachment name == PNG basename convention **A08** + **A27** enforce). ✅ `sequence` since [#729](https://github.com/firejune/rigc/issues/729) — see the `sequence` block row |
628
- | `mesh` | 🟡 | emits `type`, `uvs`, `triangles`, `vertices` (**weighted encoding only**), `hull`, `width`, `height`, `edges` (`compile.ts:1343`, and part 4's rung-6 entry measures it byte-identical to the reference), `path`, `color`. ✅ `sequence` since [#729](https://github.com/firejune/rigc/issues/729) — see the `sequence` block row. Unweighted meshes are 🚫 **A20_MESH_WEIGHTS_COHERENT** (`validate.ts:430-433`) |
629
- | `linkedmesh` | 🟡 | emits `type`, `source`, its own `path`, `width`, `height` and `color`, and `slot`/`skin`/`timelines` **only where they differ from the parser's defaults** (this attachment's slot, the default skin, true). ✅ `sequence` since [#729](https://github.com/firejune/rigc/issues/729) — see the `sequence` block row. Geometry keys on a link are refused by name — the parser returns before `readVertices`, so it reads none of them. A chain (a `source` that is itself a link) and a link to itself are refused: they resolve in file order and load `worldVerticesLength` 0 in one of the two orders, silently. `A21`/`A28` leave a link out (its rim and rows are its source's), `A04`/`A20`/`A22` read it as the mesh it resolves to, `A13` counts it as a mesh slot of its own ([#691](https://github.com/firejune/rigc/issues/691)) |
630
- | `boundingbox` | ✅ | `vertexCount` (required and cross-checked), `vertices` **or** by-name `weights`, `color`. **A33_VERTEX_ATTACHMENT_GEOMETRY** |
631
- | `path` | ✅ | `vertexCount` (required, and checked as a multiple of 3 — the parser's own `vertexCount / 3` takes a fractional size in silence), `vertices` **or** by-name `weights`, `closed`, `constantSpeed`, `color`, and `lengths` **stated or measured** (#804): a stated array — `vertexCount / 3` entries, which is what `ingest` carries from an export — is emitted as stated; an omitted one is the cumulative setup length at the end of each curve over the closed chain, taken through each influence's own bone on the unconstrained setup pose and measured as `PathConstraint` measures it — its own four-sample forward difference, about 0.5 % below the arc (AUTHORING §10.6). **A33_VERTEX_ATTACHMENT_GEOMETRY** re-checks the structure and the array's monotonicity. A `deform` timeline may key one: the array is the control points, `A39` SKIPs on it (no triangles), and `lengths` stays the setup measurement because the format has nowhere to put a per-key one — which only a `constantSpeed: false` traversal reads (AUTHORING §4.11) |
632
- | `point` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
633
- | `clipping` | ✅ under `--profile spine` · 🚫 under `spine-html` | `end` (refused when it names no slot), `convex`, `inverse`, `vertexCount`, geometry, `color`. **A33**, and **A11_NO_CLIPPING_ATTACHMENTS** is the renderer-profile refusal |
634
- | `sequence` block | ✅ | on a region, a mesh or a linked mesh: `count` (required — the parser's 0 loads no region), `start`, `digits`, `setup`, emitted as stated. The frames are the regions `<path><start + i>` zero-padded to `digits`, each atlased by name — the loose route's PNG of that name, or `--atlas-in`'s region — and a missing frame is refused with its number and the name looked for. A `setup` past the end (clamped), a fraction, an `image` or a `generator` beside it, and a `sequence` on any other kind are refused by name. **A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES** ([#729](https://github.com/firejune/rigc/issues/729)) |
635
-
636
- Across all six types rigc emits, the attachment's own **`name`** is written exactly when the rig
637
- spec states it, verbatim, as the attachment's first key — and never derived
638
- ([#796](https://github.com/firejune/rigc/issues/796)). Part 1-5 above states why it matters:
639
- `name` defaults to the placeholder and `path` defaults to `name`, so it is the runtime's
640
- `Attachment.name` and, on a type that draws, the region it resolves; nothing resolves an
641
- attachment by it. From [#541](https://github.com/firejune/rigc/issues/541) to #796 rigc composed
642
- `<skin>/<placeholder>` for a placeholder several skins fill, on the reading that a linked mesh
643
- resolves its source by name; it resolves it by skin, slot and key, and the editor imports the
644
- uncomposed shape. ⚠️ The **`default` skin may not be one of the skins sharing a placeholder**, and
645
- that is a `CompileError` rather than an emission rule: the editor holds no placeholder the default
646
- skin shares with a named one in either spelling — named, the export re-keys the attachment by its
647
- name and the slot's setup attachment stops resolving; unnamed, the import is refused with
648
- `Multiple attachments have the same name` ([#567](https://github.com/firejune/rigc/issues/567),
649
- Spine 4.3.26, round trips 7 and 8). Of the twelve editor exports in `examples/`, **0**
650
- attachments state a `name`, because all twelve declare one skin.
651
-
652
- Mesh geometry is generated by exactly three procedural generators (`mesh.ts`): `buildRingMesh`
653
- (three concentric rings + hub, outer two pinned), `buildRibbonMesh` (a two-wide strip along a bone
654
- chain) and `buildContourMesh` (marching-squares trace of the part's own alpha → Douglas-Peucker →
655
- mitred outset → ear clipping, every vertex pinned to the slot bone). The triangulator takes a
656
- **simple** polygon only: holes are enclosed rather than cut out, and a self-intersecting or
657
- diagonally pinched silhouette is refused by name (AUTHORING §3.4). There is still no importer for
658
- editor-made meshes — those arrive as authored `uvs`/`triangles`/`weights`.
659
-
660
- **Constraints** (`compile.ts:526-568`)
661
-
662
- | Part-1 type | rigc |
663
- | --- | --- |
664
- | `physics` | ✅ — full field set: `bone`, the five components `x`/`y`/`rotate`/`scaleX`/`shearX`, `scaleY` (ScaleYMode), `inertia`/`strength`/`damping`/`mass`/`wind`/`gravity`/`mix`/`fps`/`limit`, the seven `*Global` flags and `skin` (`compile.ts:1522-1548`). A field the rig spec does not give is **omitted**, never guessed (`compile.ts:1463-1468`), so the parser's own default stands. The motion spec's `physics` tuning table is the second path into this same array and is narrower: no `scaleY`, no `*Global`, no `skin`, and it drops a value that equals the parser default even when the table gives it (`compile.ts:758-761`) |
665
- | `ik` | ✅ — `bones`, `target`, `scaleY`/`mix`/`softness`/`bendPositive`/`compress`/`stretch`/`skin` (`compile.ts:1425-1430`, landed in `c5eda3b`) |
666
- | `transform` | ✅ — `bones`, `source`, the 4.3 `properties{from→to}` model, and the full field set (`compile.ts:1431-1462`, landed in `c5eda3b`) |
667
- | `path` | ✅ — `bones`, `slot`, `positionMode`/`spacingMode`/`rotateMode` (checked against the enum names `Utils.enumValue` can resolve, where only the first letter's case is free), `rotation`, `position`, `spacing`, `mixRotate`/`mixX`/`mixY`, `skin`. The slot must carry a path attachment in some skin: `PathConstraint.update` returns on its first line otherwise, so the constraint loads and moves nothing (**A36_PATH_CONSTRAINT_EFFECTIVE**) |
668
- | `slider` | ✅ — `animation` (an animation the MOTION spec declares; the parser resolves it in a second pass and throws on a miss), `mix`, `additive`, `loop`, and one of the two models `bone` switches between: `bone` + `property`/`from`/`to`/`scale`/`max`/`local`, or the bone-less `time`. The losing model's fields are refused rather than emitted, because the parser reads each set only inside its own branch (**A37_SLIDER_CONSTRAINT_EFFECTIVE** covers the rest) |
669
- | flat `constraints[]` shape | ✅ — and **A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS** actively rejects the 4.1/4.2 shape (`validate.ts:225-235`), which is the machine form of case 6a |
670
-
671
- **Timelines** (`compile.ts:570-631`, `:877-936`, `:981-1041`)
672
-
673
- | Part-1 group | rigc |
674
- | --- | --- |
675
- | `bones.rotate` / `translate` / `scale` | ✅ (`BONE_TRACKS`, `compile.ts:75-79`) |
676
- | `bones.translatex/y`, `scalex/y`, `shear`, `shearx/y`, `inherit` | ❌ emitted (🟡 the **validator** knows their channel counts, `validate.ts:83-95`, so a hand-written file with them would pass A05) |
677
- | `slots.attachment` | ✅ — value `name`, nullable; easing is refused |
678
- | `slots.rgba` | ✅ |
679
- | `slots.rgb`, `alpha` | ✅ — `rgb` is `{time, color}` with `color` in six hex digits and `[r, g, b]` in the motion spec; `alpha` is `{time, value}`, a number, `[a]` in the spec and refused outside 0..1. Each is emitted as itself and never folded into `rgba` ([#730](https://github.com/firejune/rigc/issues/730)); two colour tracks of one slot that share a channel are refused at compile, and **A45** holds the same in a foreign file and poses each key |
680
- | `slots.rgba2` | ✅ — `{time, light, dark}`, seven channels as `[lr, lg, lb, la, dr, dg, db]` in the motion spec ([#690](https://github.com/firejune/rigc/issues/690)). 🚫 **A12_NO_DARK_COLOR** names it under `spine-html` only; **A43** poses it and compares |
681
- | `slots.rgb2` | ✅ — `{time, light, dark}`, six hex digits each, `[lr, lg, lb, dr, dg, db]` in the motion spec; the slot must declare a setup `dark` ([#730](https://github.com/firejune/rigc/issues/730)). 🚫 **A12_NO_DARK_COLOR** under `spine-html`; **A43** poses it and compares |
682
- | `physics.inertia/strength/damping/mass/wind/gravity`, `physics.mix`, `physics.reset` | ✅ — all eight (`PHYSICS_TRACKS` in `compile.ts`), authored as `tracks` entries naming `physics`. ⚠️ Their per-key defaults are the parser's, and part 1-8 above is the source: 0 on the six, 1 on `mix`. Not the constraint defaults at `:306-312` |
683
- | `ik`, `transform` | ✅ — one unnamed timeline per constraint, keyed by the motion spec's `ik` / `transform` arrays |
684
- | `path.position/spacing/mix`, `slider.time/mix` | ✅ (`PATH_TRACKS` / `SLIDER_TRACKS`) — authored as `tracks` entries naming `path` or `slider`, the same shape as `physics`, since both groups put named timelines under a constraint name. `path.mix` is one timeline of three channels |
685
- | `attachments.<skin>.…deform` | ❌ (validator knows it: 1 channel, `validate.ts:104-107`) |
686
- | `attachments.…sequence` | ✅ — the motion spec's per-animation `sequence` array of `{ skin?, slot, attachment, keys: [{ t, mode?, index?, delay? }] }`, each field emitted only where stated. A mode outside the seven (read as `hold`), a fractional or out-of-range `index` (truncated, clamped), an advancing mode at an effective delay of 0 (never advances), an attachment with no `sequence` block (every mode shows its one region) and a linked mesh that plays its source's timelines (the key is applied to nothing) are refused by name. **A46** poses every key at mid-frame samples against the file's own statement ([#729](https://github.com/firejune/rigc/issues/729)) |
687
- | `drawOrder` | ✅ — the motion spec's per-animation `drawOrder` array of `{ t, offsets: [{ slot, offset }] }` (`compile.ts:823`, `:850`, `compileDrawOrder` `:1918-1970`). A key with no `offsets`, and a key with an empty one, both emit the parser's reset-to-setup encoding, so two spellings cannot make two files. An unknown slot, a slot offset twice in one key, a non-whole offset and a landing outside the emitted slots are each refused by name, and **A31_DRAW_ORDER_OFFSETS_RESOLVE** re-checks the emitted file |
688
- | `drawOrderFolder` | ❌ — editor bookkeeping, 4.3-only |
689
- | `events` (+ the `root.events` block) | ✅ — the rig spec declares the names (`rig.ts` `RigEvent`), the motion spec's per-animation `events` array fires them, and **A32_EVENT_KEYS_RESOLVE** checks the three ways the timeline goes wrong quietly. `int`/`float`/`string` overrides and `audio`/`volume`/`balance` all round-trip |
690
- | animation `color` | ❌ |
691
-
692
- **Curves** — ✅ all three encodings. Linear = no `curve` key; `"stepped"` passes through as the string
693
- (`compile.ts:917-918`); bezier is computed from **named easings** (`motion.easings`, four normalised
694
- handles) into **absolute (time, value) control points**, `bezierForChannel` (`compile.ts:121-135`),
695
- emitted 4 numbers × channel count in field order. This is the correct transform — writing the
696
- normalised handles straight in is the silent bug that costs an authoring loop.
697
-
698
- **Atlas** (`compile.ts:346-365`) — one part per page, no packer.
699
-
700
- | Part-1 field | rigc |
701
- | --- | --- |
702
- | page name | ✅ (PNG path relative to the atlas file) |
703
- | `size: w, h` | ✅ from PNG measurement (`png.ts`), re-checked against the file by **A06** |
704
- | `filter: Linear, Linear` | ✅ hard-coded |
705
- | `pma: false` | ✅ hard-coded; a `true` is 🚫 **A06** |
706
- | `format:`, `repeat:` | ❌ (both optional; `format` is discarded by the reader anyway) |
707
- | region `bounds: 0,0,w,h` + `offsets: 0,0,w,h` | ✅ — the 4.x compact form |
708
- | region `rotate: 0` | ✅ always zero; anything else is 🚫 **A06** ("there is no packer, so nothing can be") |
709
- | region `index:`, `split:`, `pad:` | ❌ |
710
- | multi-region pages | ❌ by construction; **A06** requires each region's UVs to be exactly (0,0)-(1,1) |
711
-
712
- **Binary `.skel`** — ❌ not emitted, and out of scope: rigc's whole validation strategy is a JSON
713
- round-trip through spine-core.
714
-
715
- ### 2.2 The assertions, classified
716
-
717
- This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for any consumer.
718
- **Renderer-profile** = the file is valid Spine but violates a spine-html / canvas-budget policy.
719
- **Archetype** = a structural rule about *our* rigs that no Spine consumer would care about.
720
-
721
- | Assertion | Kind | What it forbids |
722
- | --- | --- | --- |
723
- | `A00_ROUNDTRIP_PARSE` | validity | JSON or atlas that spine-core refuses (cases 6d, 6e) |
724
- | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | validity | `ik`/`transform`/`path`/`physics` at top level (case 6a) |
725
- | `A02_NO_BONE_TRANSFORM_KEY` | validity | bone `transform` instead of `inherit` (case 6b) |
726
- | `A03_REGION_WIDTH_HEIGHT_FINITE` | validity | `NaN`/non-positive region size (case 6c) |
727
- | `A04_MESH_TRIANGLES_AND_ENCODING` | validity | missing/misaligned `triangles`, out-of-range indices, incoherent vertex run (case 6f) |
728
- | `A05_CURVE_ARRAY_LENGTH` | validity | short/non-finite bezier arrays, curves on curve-less timelines (case 6g) |
729
- | `A07_ATLAS_TEXT_SHAPE` | validity | blank line inside a page block, indented region name (the two `TextureAtlas` traps) |
730
- | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | validity + policy | attachment→region miss is validity; **"v0 requires them identical"** is policy |
731
- | `A09_ANIMATION_DURATION_MATCHES_SPEC` | spec-fidelity | compiled last-key time ≠ declared duration |
732
- | `A10_NO_NAN_AFTER_STEPPING` | validity | NaN world transforms / colours after stepping every animation (120 frames) |
733
- | `A16_SKELETON_VERSION_4_3` | validity (portability) | a `spine` string outside `4.3.x` |
734
- | `A17_ATLAS_PAGE_FILES_EXIST` | validity | a page PNG not on disk |
735
- | `A18_DETERMINISTIC_EMIT` | tool contract | recompiling differs byte-for-byte |
736
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | **mixed** | size≠PNG is **validity** (case 6h) — measured rather than inherited ([#715](https://github.com/firejune/rigc/issues/715)): the runtime's UVs are a fraction of the DECLARED size, so a rescaled page draws, and the clause stays validity because every texel reader breaks on it (two of them rigc's own, under both profiles) and the format can state the same art truthfully with `scale:`, which the message prints. And so is a region whose rectangle is outside the page it names, rotation honoured ([#694](https://github.com/firejune/rigc/issues/694)); `pma:true`, region rotation, and two regions on one page over the same texels are **renderer-profile** |
737
- | `A11_NO_CLIPPING_ATTACHMENTS` | **renderer-profile** | clipping attachments — "the renderer skips them silently" |
738
- | `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 |
739
- | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | validity | a slot `dark` the parser drops or reads as NaN, an `rgba2` or `rgb2` timeline on a slot with no dark colour to pose, or a key whose posed light/dark is not what it states |
740
- | `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 |
741
- | `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | validity | an `rgb` or `alpha` timeline beside another colour timeline of the slot that poses the same channel — the later in the file overwrites the other at every time — or a key of one the pose does not reproduce |
742
- | `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | validity | a numbered series that loads as something other than what the file states — a `sequence` block with no `count` (0 regions) or a `setup` past the end (clamped), or a `sequence` key whose `mode` is outside the seven (read as `hold`), whose `index` is fractional or past the end, whose advancing mode runs at an effective delay of 0, or that steps an attachment with no block — and then the pose: every key sampled mid-frame, the region shown held to the frame the file's statement gives ([#729](https://github.com/firejune/rigc/issues/729)) |
743
- | `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | an ik constraint at `mix` 0 at setup that no animation keys away from 0 — `update()` returns on it, and the rig parses and moves nothing ([#765](https://github.com/firejune/rigc/issues/765)) |
744
- | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | a transform constraint whose mixes for the properties it drives are all 0 at setup and on every value a key poses, or one that drives no property ([#765](https://github.com/firejune/rigc/issues/765)) |
745
- | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
746
- | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
747
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay part none of whose texels is below full alpha — refused unopened when the file has no alpha channel and no `tRNS` chunk, and by its texels otherwise, on a loose page and a shared one alike |
748
- | `A20_MESH_WEIGHTS_COHERENT` | **renderer-profile / archetype** | **unweighted meshes**, weights not summing to 1, out-of-range bone indices |
749
- | `A21_MESH_RIM_PINNED` | archetype | ring/ribbon/contour rim not pinned |
750
- | `A22_MESH_UVS_IN_UNIT_RANGE` | validity-ish | UVs outside 0..1 |
751
- | `A15_IDLE_NO_MESH_BONE_KEYS` | archetype | `idle` keying a mesh-driving bone |
752
- | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | validity-ish | a physics constraint that parses and does nothing |
753
- | `A24_AXIS_SPACE_STROKE` | archetype | screen-space stroke keys |
754
- | `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares detached, parented under the bone it must stay clear of |
755
- | `A26_SLOT_DRAW_ORDER` | archetype | draw order ≠ the archetype's slot table |
756
- | `A32_EVENT_KEYS_RESOLVE` | validity | an event key firing an undeclared name (parser throws), key times going backwards (silent — frames are filled in array order), `volume`/`balance` on an event with no `audio` (silent) |
757
- | `A33_VERTEX_ATTACHMENT_GEOMETRY` | validity | a bounding box or clipping polygon with a missing or disagreeing `vertexCount` (`undefined << 1` = 0, so the coordinates are decoded as a weight run), a weighted run of the wrong length, an out-of-range bone index, or a clipping `end` naming no slot (`findSlot` returns null and the null is assigned) |
758
- | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | policy | region name ≠ PNG basename |
759
- | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | ribbon rows with divergent weights |
760
- | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | stroke past the measured contact depth |
761
- | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | stroke past the measured containment ceiling |
762
-
763
- **Nine assertions are renderer-profile or profile-mixed** (A06 partly, A11, A12, A13, A14, A19, A20,
764
- plus A08/A27's "identical names" policy). Every one of them would fire on real Spine example data.
765
- They are correct for spine-html; they are wrong as *spec* gates, and they must become a
766
- profile switch (`--profile spine-html` vs `--profile spine`) before rung 1.
767
-
768
- **Twelve are archetype assertions** (A15, A21, A24–A26, A28–A30, plus the parts of A20/A21 that
769
- encode ring/ribbon geometry). They are already gated on `input.rig` being present and **SKIP**
770
- when validating a bare directory (`validate.ts:47-55`, `:808`), which is the right shape — nothing
771
- needs to change there, they simply will not run on example data.
772
-
773
- ### 2.3 What the round-trip checks — and what it lets through
774
-
775
- **Checked**: the artifact is parsed by the real `TextureAtlas` and `SkeletonJson` (A00), then every
776
- animation is stepped 120 frames through a real `Skeleton` + `AnimationState` looking for non-finite
777
- world transforms and colours (A10, `validate.ts:703-733`), then the compiler is re-run and the two
778
- emits compared byte-for-byte (A18).
779
-
780
- **Let through silently** — the honest list:
781
-
782
- 1. **Whole timeline groups the walker never visits.** `walkTimelines` (`validate.ts:1159-1199`)
783
- descends only `animations.<a>.bones`, `.slots`, `.physics`, and `.attachments`. An `ik`,
784
- `transform`, `path`, `slider`, `drawOrder`, `drawOrderFolder`, or `events` block is **not visited
785
- at all**, so A05 never sees its curves. Adding those emitters without extending the walker
786
- silently disables the format's most dangerous check.
787
- 2. **Unknown top-level and per-map keys.** Nothing enumerates what the parser reads, so a typo'd
788
- key (`"inherits"`, `"tringles"`) is invisible to both parser and validator.
789
- 3. **Semantics the parser cannot see.** A wrong-but-parseable `inherit` value, a `blend` mode, a
790
- slot `visible: false`, a linked mesh pointing at the wrong source — none are checked.
791
- 4. **`skin` / `skinRequired` interactions** — rigc emits one skin, so nothing is exercised.
792
- 5. **A05's own escape hatch is a fail, not a pass** (`validate.ts:275-278`): an unrecognised timeline
793
- name inside a *visited* group fails with `unchecked <kind> timeline "<name>" — extend the
794
- validator`. That is good design, and it means new bone/slot/physics timelines cannot be added
795
- without touching the channel tables. It does **not** apply to the groups in item 1.
796
-
797
- `selftest.ts` runs 35 named mutants (M01–M35) across three fixtures, each mutant asserting that a
798
- specific assertion fires. The mutant set mirrors the assertion set, so the same blind spots apply:
799
- there is no mutant for an IK or draw-order timeline because there is no emitter and no walker for
800
- one.
801
-
802
- > 🔼 Both halves of that paragraph are now stale. The walker reaches all eleven groups and the
803
- > mutants that prove it exist; the suite runs on fixtures it **generates**, so it needs no
804
- > project's art. Current shape: [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md), *The selftest and its fixtures*.
805
-
806
- ### 2.4 The input model — where the rig actually comes from
807
-
808
- Two data files in, per `types.ts:1-13`:
809
-
810
- - **cut manifest** (`FaceManifest`) — owns *geometry*: `crop`, per-part `offset`/`size`,
811
- `polygon`, `state_machine`, `anchors` (bone positions in crop pixels), `axis`, `stroke`.
812
- - **motion spec** (`MotionSpec`, `spec: 'rigc-motion/1'`) — owns *time*: `easings`, `groups`,
813
- `setup` (per-slot setup pose), `physics` constraint table, `animations{ tracks[] }`, `mix`.
814
-
815
- **The bone tree is code, not data.** `src/archetype.ts` hard-codes three archetypes:
816
-
817
- | Archetype | Bone tree | Mesh budget | Slots |
818
- | --- | --- | --- | --- |
819
- | overlay, tier 1 (`:65-71`) | a two-bone root chain, then **one bone per slot**, auto-named after the slot and pinned at the part-window centre | 0 | derived from the manifest's parts |
820
- | overlay, tier 2 (`:84-88`) | identical | 3 | identical |
821
- | articulated (`:109-161`) | **18 explicitly named bones**: a camera handle, a base, a mass with two soft-body controls, an axis bone carrying four subtrees (a travelling part and its tip, a rim with four grips, a detached emitter with a three-link chain) | 3 | a fixed `slotBone` map of **9 slots** with a fixed `slotOrder` |
822
-
823
- The manifest supplies *positions* for those bones (`anchors`), never their *names*, *parentage*, or
824
- *count*. Consequences, stated plainly:
825
-
826
- - A slot not in the archetype's `slotBone` table is a **hard compile error**
827
- (`compile.ts:412-417`: *"extend its slot table rather than inventing one"*).
828
- - A control bone not in the archetype tree is a hard error (`compile.ts:420-426`).
829
- - Therefore **no example project can be compiled by rigc today at any rung** — not because a feature
830
- is missing, but because there is no way to *state* a skeleton whose bone tree is not one of these
831
- three. This is the single largest item in Part 4(b) and it gates everything else.
832
-
833
-
834
- ---
835
-
836
- ## Part 3 — what the example ladder actually uses
837
-
838
- Corpus: the nine official example projects on branch `4.3`, fetched by
839
- [`scripts/fetch-examples.sh`](https://github.com/firejune/rigc/blob/main/scripts/fetch-examples.sh) into the gitignored `examples/<name>/`
840
- (**12 skeleton JSON files** — some examples ship more than one — **10 `.atlas` files**, 8 `license.txt`).
841
- Counted by [`bench/count_features.ts`](https://github.com/firejune/rigc/blob/main/bench/count_features.ts); full output in
842
- [`feature_matrix.json`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.json) and [`feature_matrix.csv`](https://github.com/firejune/rigc/blob/main/docs/feature_matrix.csv)
843
- (12 rows × 135 columns), both regenerated by `bun run bench:usage`.
844
-
845
- > ⚠️ **Verification note.** The timeline columns below were re-derived independently from the raw JSON
846
- > and reconciled against the CSV before publication. Where a prose summary and the machine output
847
- > disagreed, the machine output won. Everything in these tables is from the CSV.
848
-
849
- ### 3.0 Corpus-wide facts
850
-
851
- - **Every one of the 12 files reports `"spine": "4.3.75-beta"`.** No legacy exports in the corpus.
852
- - **Every file with constraints uses the flat 4.3 `constraints[]` shape**; all four transform-constraint
853
- users use the 4.3 `source` + `properties` model. **The legacy 4.0–4.2 array shape never appears**, so
854
- this corpus does *not* exercise case 6a — do not treat "the ladder is green" as evidence
855
- that a legacy-shape importer works.
856
- - **Every atlas uses the 4.x compact `bounds:` / `offsets:` region form.** The deprecated
857
- `xy:`/`size:`/`orig:`/`offset:` region form never appears.
858
- - **Zero unknown keys anywhere** — every top-level, bone, slot, and attachment key in all 12 files is
859
- one the 4.3 parser reads. The corpus is a clean specimen of exactly the surface Part 1 enumerates.
860
- - **No file uses:** named skins (every file has one skin, `default`), linked meshes, path constraints,
861
- slider constraints, sequences, slot `dark` colour, `drawOrderFolder`, bone `visible`, or path/point
862
- attachments.
863
- - **Nine of ten atlases carry a page `scale:` line** that spine-ts ignores (§1.10).
864
-
865
- ### 3.1 Structure and attachments
866
-
867
- | File | bones | slots | skins | region | mesh | bbox | clip | mesh W/U | mesh verts | mesh tris | max verts in one |
868
- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
869
- | `1-weight-and-mass-balls-ess` | 9 | 8 | 1 | 8 | 0 | 0 | 0 | – | – | – | – |
870
- | `1-weight-and-mass-drop-ess` | 4 | 5 | 1 | 5 | 0 | 0 | 0 | – | – | – | – |
871
- | `2-the-12-principles-ess` | 12 | 17 | 1 | 17 | 0 | 0 | 0 | – | – | – | – |
872
- | `3-timing-and-spacing-ess` | 3 | 2 | 1 | 2 | 0 | 0 | 0 | – | – | – | – |
873
- | `4-wave-principle-ess` | 9 | 9 | 1 | 9 | 0 | 0 | 0 | – | – | – | – |
874
- | `5-squash-and-stretch-ess` | 16 | 13 | 1 | 29 | 0 | 0 | 0 | – | – | – | – |
875
- | `6-arcs-pro` | 14 | 4 | 1 | 2 | 2 | 0 | 0 | **2 / 0** | 44 | 40 | 40 |
876
- | `7-anticipation / sack-pro` | 31 | 3 | 1 | 0 | 3 | 0 | 0 | **3 / 0** | 193 | 230 | 96 |
877
- | `8-follow-through-pro-ball` | 12 | 2 | 1 | 0 | 2 | 0 | 0 | **2 / 0** | 34 | 30 | 30 |
878
- | `8-follow-through-pro-pendulum` | 7 | 6 | 1 | 6 | 0 | 0 | 0 | – | – | – | – |
879
- | `spineboy-ess` | 18 | 20 | 1 | 26 | 0 | **1** | 0 | – | – | – | – |
880
- | `spineboy-pro` | **67** | **52** | 1 | 66 | **12** | **1** | **1** | **10 / 2** | 322 | 430 | 74 |
881
-
882
- All meshes in the corpus carry both `edges` and `hull`.
883
-
884
- ### 3.2 Timelines actually keyed (verified twice)
885
-
886
- | File | bone timelines | slot timelines | constraint / other |
887
- | --- | --- | --- | --- |
888
- | `1-w&m-balls-ess` | translate, translatex, translatey, scale, shear | **rgba** | — |
889
- | `1-w&m-drop-ess` | *(none — a static skeleton)* | — | — |
890
- | `2-the-12-principles-ess` | rotate, translate, translatex, translatey, scale, shear | attachment, **rgba** | — |
891
- | `3-timing-and-spacing-ess` | rotate, translatex, translatey | — | — |
892
- | `4-wave-principle-ess` | rotate, translatex, translatey, scale | attachment | — |
893
- | `5-squash-and-stretch-ess` | rotate, translate, translatex, translatey, scale | attachment, **rgba** | **drawOrder** (2 keys) |
894
- | `6-arcs-pro` | rotate, translate, translatex, translatey, scale | attachment | *(4 transform constraints, but **no** transform timeline — they are static)* |
895
- | `7-anticipation / sack-pro` | rotate, translate, translatex, translatey, scale | — | **transform** (11 keys), **physics**: inertia, strength, damping, mass, wind, mix, **deform** (1 key) |
896
- | `8-follow-through-pro-ball` | rotate, translate, translatex, translatey | — | *(4 transform constraints, no transform timeline)* |
897
- | `8-follow-through-pro-pendulum` | rotate, translatex, translatey | — | — |
898
- | `spineboy-ess` | rotate, translate, scale | attachment, **rgba** | **drawOrder** (1 key), **events** (5 keys) |
899
- | `spineboy-pro` | rotate, translate, scale, shear | attachment, **rgba** | **ik** (29 keys), **transform** (6 keys), **deform** (9 keys), **events** (5 keys) |
900
-
901
- Notable absences across the whole corpus: `inherit`, `scalex`/`scaley`, `shearx`/`sheary`,
902
- `rgb`, `alpha`, `rgba2`, `rgb2`, `sequence`, `drawOrderFolder`, `path.*`, `slider.*`,
903
- `physics.gravity`, `physics.reset`.
904
-
905
- ### 3.3 Constraints, curves, events, blend, inheritance
906
-
907
- | File | ik | transform | path | physics | slider | curve linear / stepped / bezier | bezier array lengths | slot blend ≠ normal | bone inherit ≠ Normal | events defined |
908
- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: |
909
- | `1-w&m-balls-ess` | 0 | 0 | 0 | 0 | 0 | 206 / 1 / 44 | 4 | 0 | 0 | 0 |
910
- | `1-w&m-drop-ess` | 0 | 0 | 0 | 0 | 0 | 0 / 0 / 0 | — | 0 | 0 | 0 |
911
- | `2-the-12-principles-ess` | 0 | 0 | 0 | 0 | 0 | 150 / 127 / **1010** | 4, 8 | **8** (4 additive, 4 multiply) | 4 × `noRotationOrReflection` | 0 |
912
- | `3-timing-and-spacing-ess` | 0 | 0 | 0 | 0 | 0 | 2 / 1 / 58 | 4 | 0 | 0 | 0 |
913
- | `4-wave-principle-ess` | 0 | 0 | 0 | 0 | 0 | 36 / 8 / 470 | 4 | **2** (1 additive, 1 multiply) | 1 × `noRotationOrReflection` | 0 |
914
- | `5-squash-and-stretch-ess` | 0 | 0 | 0 | 0 | 0 | 162 / 82 / **1665** | 4, 8 | 0 | **8 × `onlyTranslation`** | 0 |
915
- | `6-arcs-pro` | 0 | **4** | 0 | 0 | 0 | 18 / 7 / 498 | 4, 8 | 0 | 0 | 0 |
916
- | `7-anticipation / sack-pro` | 0 | **4** | 0 | **20** | 0 | 171 / 62 / 757 | 4, 8 | 0 | 3 × `noScale` | 0 |
917
- | `8-follow-through-pro-ball` | 0 | **4** | 0 | 0 | 0 | 22 / 5 / 328 | 4, 8 | 0 | 0 | 0 |
918
- | `8-follow-through-pro-pendulum` | 0 | 0 | 0 | 0 | 0 | 1 / 0 / 156 | 4 | 0 | 0 | 0 |
919
- | `spineboy-ess` | 0 | 0 | 0 | 0 | 0 | 43 / 8 / 526 | 4, 8 | 1 additive | 0 | **1** (5 keys) |
920
- | `spineboy-pro` | **7** (2 multi-bone) | **7** | 0 | 0 | 0 | 90 / 88 / **1136** | 4, 8, **16** | **25 additive** | 4 × `noRotationOrReflection` | **1** (5 keys) |
921
-
922
- Bezier array length is `4 × channels` (§1.9). Observed lengths **4** (1-channel: rotate, translatex/y),
923
- **8** (2-channel: translate, scale) and **16** (4-channel: rgba, spineboy-pro only). Never 12/24 —
924
- so no example ever puts a bezier on a `transform`, `path mix`, or `rgb` timeline.
925
-
926
- Nonessential data is present throughout: `hash` and `images` in all 12; bone `color` in 9 files
927
- (up to 42 bones in spineboy-pro); bone `icon` in 8 files.
928
-
929
- ### 3.4 Atlases
930
-
931
- | Atlas | pages | regions | region form | `scale:` | `pma:` | rotated regions | index / split / pad |
932
- | --- | ---: | ---: | --- | --- | --- | --- | --- |
933
- | `1-weight-and-mass.atlas` | 1 | 13 | compact | `0.5` | absent | **1** (`rotate: 90`, `ground-bg`) | — |
934
- | `2-the-12-principles.atlas` | 1 | 15 | compact | `0.4` | absent | 0 | — |
935
- | `3-timing-and-spacing.atlas` | 1 | 2 | compact | `0.5` | absent | 0 | — |
936
- | `4-wave-principle.atlas` | 1 | 8 | compact | `0.5` | absent | 0 | — |
937
- | `5-squash-and-stretch.atlas` | 1 | 29 | compact | `0.5` | absent | **1** (`rotate: 90`) | — |
938
- | `6-arcs.atlas` | 1 | 4 | compact | `0.5` | absent | 0 | — |
939
- | `7-anticipation.atlas` | 1 | 3 | compact | `0.5` | absent | **1** (`rotate: 270`, `cape-back`) | — |
940
- | `8-follow-through.atlas` | 1 | 8 | compact | `0.5` | absent | 0 | — |
941
- | `spineboy.atlas` | 1 | 50 | compact | `0.5` | absent | 0 | — |
942
- | `spineboy-run.atlas` | 1 | 10 | compact | absent | **`true`** | 0 | **`index: 8`** on all 10 |
943
-
944
- Two things worth flagging:
945
-
946
- - **`rotate: 270` exists in the wild** (`7-anticipation`), so a reader that treats `rotate` as a
947
- boolean is wrong on real data — matching `TextureAtlas.ts:91-97`'s three-way branch.
948
- - **`spineboy-run.atlas` is a degenerate file**: ten region entries, all named `spineboy-pro-run`,
949
- all with `index: 8` and identical `bounds`. Byte length matches upstream (869 B), so this is what
950
- the repository ships, not a fetch artefact. It is not referenced by either spineboy skeleton
951
- (both use `spineboy.atlas`) and it is the **only** `pma: true` page in the corpus. Treat it as an
952
- outlier, not as a ladder target.
953
-
954
- ### 3.5 Licences — **not uniform, and one example has none**
955
-
956
- Eight of nine examples ship `license.txt`; **`7-anticipation` has none** (verified: 404 for both
957
- `license.txt` and `LICENSE.txt`, and no licence file in that example's directory listing). The eight
958
- present files fall into **five byte-distinct variants that differ only in the copyright line**:
959
-
960
- | Variant | Examples | Copyright line |
961
- | --- | --- | --- |
962
- | A | `1-weight-and-mass`, `2-the-12-principles`, `4-wave-principle` | `Copyright (c) 2021-2025, Esoteric Software LLC` |
963
- | B | `3-timing-and-spacing`, `5-squash-and-stretch` | `Copyright (c) 2021-2025, Esoteric Software` |
964
- | C | `6-arcs` | `Copyright (c) 2022-2025, Esoteric Software` |
965
- | D | `8-follow-through` | `Copyright (c) 2024-2025, Esoteric Software` |
966
- | E | `spineboy` | `Copyright (c) 2013, Esoteric Software LLC` |
967
-
968
- The body is identical in all five:
969
-
970
- > The images in this project may be redistributed as long as they are accompanied by this license
971
- > file. The images may not be used for commercial use of any kind.
972
- >
973
- > The project file is released into the public domain. It may be used as the basis for derivative work.
974
-
975
- **What this means for the ladder**, stated as a chain and not as legal advice:
976
-
977
- 1. **Images** — redistributable *only* alongside the licence file, and **not for commercial use**.
978
- A benchmark that ships the example PNGs is fine as a non-commercial research artefact; shipping
979
- them inside a commercial product is not.
980
- 2. **Project files / exports** — public domain. So the `.json`/`.atlas` we are trying to *reproduce*
981
- carry no restriction, and a rigc-generated look-alike inherits nothing.
982
- 3. **`7-anticipation` has no grant on file at all.** Its images and `sack-pro.json` are governed only
983
- by the repository-level Spine Runtimes License. It is the one rung where "we redistributed the
984
- images" is not covered by an explicit permission — **use it for measurement, do not vendor its
985
- images**. Since it is also the physics rung, this is worth deciding before rung 7.
986
- 4. Independent of any of this, rigc links spine-core, so its own `NOTICE.md` chain (each user needs a
987
- Spine Editor licence) applies regardless of which example is being reproduced.
988
-
989
-
990
- ---
991
-
992
- ## Part 4 — the gap list, ordered by the ladder
993
-
994
- Crossing Part 2 with Part 3. Categories, as requested:
995
- **(a) emitter** — new attachment / constraint / timeline / atlas kinds ·
996
- **(b) input model** — what the spec files must be able to *say* ·
997
- **(c) validator** — assertions that must become a profile switch or be widened ·
998
- **(d) JSON-inexpressible** — things the format genuinely cannot hold.
999
-
1000
- **Nothing below was implemented when this was written. This is a survey.** Three of its items
1001
- have been built since — see the banner at the top of this document and [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) for
1002
- which; the requirement text itself is left exactly as surveyed.
1003
-
1004
- ### 4.0 Three blockers that precede rung 1
1005
-
1006
- These are not per-rung; they gate the whole ladder and should be settled before rung 1 is attempted.
1007
-
1008
- | # | Blocker | Category | Detail |
1009
- | --- | --- | --- | --- |
1010
- | **B1** | **The bone tree is code** | (b) | `archetype.ts` hard-codes all three archetypes. A slot outside the archetype's `slotBone` table is a compile error (`compile.ts:412-417`); a control bone outside its tree likewise (`:420-426`). **No example fits any of the three.** rigc needs a "skeleton-as-data" input — bones with explicit names/parents/transforms — before any rung is reachable. |
1011
- | **B2** | **A16 rejects every example** | (c) | `A16_SKELETON_VERSION_4_3` tests `/^4\.3(\.\d+)?$/` (`validate.ts:216`). All twelve example files declare `"4.3.75-beta"`, which **fails**. One-line fix, but it fails on rung 1 file 1. |
1012
- | **B3** | **The atlas model is one-part-per-page** | (a)+(c) | rigc emits `bounds: 0,0,w,h` per page and **A06** demands every region's UVs be exactly (0,0)-(1,1). Every example ships a **packed** atlas (13, 15, 29, 50 regions on one page). rigc has no packer *and* no importer for a pre-packed atlas. Reproducing a rung means either accepting a hand-supplied atlas or writing a packer. |
1013
-
1014
- ### 4.1 Cumulative per-rung requirements
1015
-
1016
- Each row lists only what is **new at that rung**. Read cumulatively: to stand on rung N you need
1017
- rows 1..N plus §4.0.
1018
-
1019
- #### Rung 1 — `1-weight-and-mass` (2 skeletons: `balls-ess`, `drop-ess`)
1020
-
1021
- | Cat | Requirement |
1022
- | --- | --- |
1023
- | (a) | Bone timelines **`translatex`**, **`translatey`**, **`shear`** — rigc emits only `translate`/`scale`/`rotate` |
1024
- | (a) | Bone setup **`length`** (3 bones in `drop-ess`) — cosmetic in a renderer, but part of a faithful reproduction |
1025
- | (a) | Region attachment **`rotation`** on 3 attachments *(rigc emits `rotation` already, as a bone-cancel term — it would need to become an authorable value)* |
1026
- | (a) | Atlas: **multi-region packed page**, per-region `bounds`/`offsets`, **`rotate: 90`**, page `scale:` line |
1027
- | (b) | Bone tree as data (B1); a skeleton with **zero animations** (`drop-ess`) — today `A09` compares every declared duration and `compile.ts:622` refuses a mismatch, and the motion-spec schema has no "static rig" mode |
1028
- | (c) | **A06** — "region covers the whole page" and "no rotated region" must move behind the profile switch |
1029
- | (c) | **A16** (B2), **A26** (`slotOrder` is an archetype table; example slot order comes from the file), **A08/A27** (region-name == PNG-basename is meaningless for a packed atlas) |
1030
- | (d) | none |
1031
-
1032
- #### Rung 2 — `2-the-12-principles`
1033
-
1034
- | Cat | Requirement |
1035
- | --- | --- |
1036
- | (a) | **Slot `blend`** — first appearance: 4 additive + 4 multiply slots |
1037
- | (a) | **Bone `inherit`** — first appearance: 4 × `noRotationOrReflection` |
1038
- | (b) | The manifest must be able to declare per-slot blend and per-bone inherit |
1039
- | (c) | A05 already knows the channel counts for every bone timeline; no change needed for this rung's curves (4- and 8-length beziers) |
1040
- | (d) | none |
1041
-
1042
- #### Rung 3 — `3-timing-and-spacing`
1043
-
1044
- Nothing new. **This is the smallest skeleton in the corpus (3 bones, 2 slots, 2 animations) and is
1045
- the natural *first* rung** despite its number — it needs only B1–B3 plus `translatex`/`translatey`.
1046
-
1047
- #### Rung 4 — `4-wave-principle`
1048
-
1049
- Nothing structurally new (attachment timeline, blend, inherit all arrived by rung 2). It is a
1050
- **volume** test: 9 bones, 9 slots, 3 animations, 470 bezier keys.
1051
-
1052
- #### Rung 5 — `5-squash-and-stretch`
1053
-
1054
- | Cat | Requirement |
1055
- | --- | --- |
1056
- | (a) | **`drawOrder` timeline** — 🔴 first appearance (2 keys). rigc emits none, and the *validator does not walk the group at all* (`validate.ts:1166-1170`) |
1057
- | (a) | **Bone `inherit: onlyTranslation`** (8 bones); non-unit setup `scaleX`/`scaleY` (3 bones) |
1058
- | (b) | A draw-order track type in the motion spec (a list of slot offsets over time) |
1059
- | (c) | Extend `walkTimelines` to visit `drawOrder`, and give A05 a "no curve here" entry for it |
1060
- | (d) | none |
1061
-
1062
- #### Rung 6 — `6-arcs`
1063
-
1064
- > ✅ **Measured, 2026-08-23.** The rows below were written as predictions; the
1065
- > transcription in [`bench/transcriptions/6-arcs/`](https://github.com/firejune/rigc/tree/main/bench/transcriptions/6-arcs/)
1066
- > settled them. It compiles green under `--profile spine` and `bench 6` reports
1067
- > **1.000 on all 44 measures in all six sections**, so the rig spec expresses this
1068
- > skeleton — weighted meshes, `edges` and the 4.3 transform constraints alike. A
1069
- > field-by-field comparison against the reference export leaves 49 differences and
1070
- > every one is benign: 39 are defaults rigc writes explicitly where the editor
1071
- > omits them, 3 are editor bookkeeping (`hash`, `images`, `audio`), 1 is the runtime
1072
- > version string, and **6 are bone `icon`** — an editor decoration the rig spec had
1073
- > no field for and the only thing here it could not say. `RigBone.icon` carries it
1074
- > since issue #47; the transcription itself has not been re-cut. What did not
1075
- > survive contact is noted per row.
1076
-
1077
- | Cat | Requirement |
1078
- | --- | --- |
1079
- | (a) | **Transform constraints** — 🔴 first appearance (4). Full 4.3 `source` + `properties{from→to}` model (§1.4), which is the least-documented constraint in the format. ✅ Expressible and round-trips exactly; `RigTransformConstraint` already carried the 4.3 shape |
1080
- | (a) | **Weighted meshes from authored geometry** — 🔴 first appearance. ~~rigc can emit weighted meshes, but only from `buildRingMesh`/`buildRibbonMesh`. An arbitrary 40-vertex/38-triangle mesh cannot be expressed~~ ⚠️ **This was wrong.** `RigMeshAttachment` takes authored `uvs`/`triangles`/`vertices`/`hull`, and `buildRigMesh` copies them verbatim. Both of 6-arcs' meshes round-trip to 1e-5 |
1081
- | (a) | Mesh **`edges`** key ~~(rigc emits `hull` but not `edges`)~~ ✅ emitted from `RigMeshAttachment.edges` and byte-identical to the reference. 🚨 But **nothing measures it** — deleting `edges` from the rig still scores 1.000 on all nine attachment measures, so this rung's own gating feature is invisible to `bench` (issue #46) |
1082
- | (b) | Mesh geometry as data — vertices, triangles, uvs, per-vertex bone weights — instead of a generator name plus a polygon. ✅ Present. ~~🚨 But the weights bind bones by **index into the emitted bone array**, not by name — inserting a bone rebinds every vertex with the gate still green (issue #45)~~ ✅ **Fixed.** Weights bind **by name** (`weights: [[{ bone, x, y, weight }, …], …]`) and the compiler resolves them at emit; an unknown name is a `CompileError` (selftest `RF08`). Spine's index run survives behind an explicit `"boneIndexing": "raw"`, whose cost — silence — `MR07` still measures |
1083
- | (c) | **A20** (unweighted forbidden) is satisfied here. ~~Under `--profile spine-html` its extra clause fires 11 times instead — the editor writes zero-weight bindings and that profile forbids them~~ ✅ **Fixed with #44**: both of A20's policy clauses are statements about what a rigc *generator* produces, so neither applies to authored geometry. Its coherence clauses — present, in range, summing to 1 — still do, in every profile |
1084
- | (c) | **A21_MESH_RIM_PINNED** and **A28** encode ring/ribbon topology and will fire on an arbitrary mesh. ~~✅ **Confirmed**: A21 fires 40 times on the `tail` mesh under the default profile, because `meshKinds` has no entry for an authored mesh and the lookup falls back to `'ring'`~~ ✅ **Fixed** (issue #44): `meshKinds` has a third state, `authored`, and both assertions SKIP on one with that as the reason. The whole transcription is green under the default profile; selftest `MR08` holds it |
1085
- | (c) | A13's mesh budget (≤4 slots, ≤80 tris) is **satisfied** at this rung (2 slots, max 38 tris) |
1086
- | (d) | none |
1087
-
1088
- #### Rung 7 — `7-anticipation` (`sack-pro`)
1089
-
1090
- | Cat | Requirement |
1091
- | --- | --- |
1092
- | (a) | **Physics timelines** `inertia`, `strength`, `damping`, `mass`, `wind` — rigc emits only `mix` and `reset` |
1093
- | (a) | **`transform` constraint timeline** — 🔴 first appearance as a *keyed* timeline (11 keys); rung 6's transform constraints were static |
1094
- | (a) | **`deform` timeline** — 🔴 first appearance (1 key). Also needs the `animations.<a>.attachments.<skin>.<slot>.<att>` container, which rigc never writes |
1095
- | (a) | 20 physics constraints on a 31-bone tree; bone `inherit: noScale` |
1096
- | (b) | Per-vertex deform keys in the motion spec |
1097
- | (c) | 🚨 **A13_MESH_BUDGET fails**: the `sack` mesh has **116 triangles** against a budget of 80. Renderer-profile — must become a switch |
1098
- | (c) | A24/A29/A30 (axis-space stroke, contact depth, containment ceiling) are archetype assertions and will correctly **SKIP** — no change needed |
1099
- | (⚖️) | **Licence**: this is the one example with **no `license.txt`** (§3.5). Measure against it; do not vendor its images |
1100
- | (d) | none |
1101
-
1102
- #### Rung 8 — `8-follow-through` (2 skeletons: `pro-ball`, `pro-pendulum`)
1103
-
1104
- Nothing new — transform constraints (static, 4) and weighted meshes (2, max 28 tris) both arrived at
1105
- rung 6. `pro-pendulum` is region-only and is effectively a rung-3-class file.
1106
-
1107
- #### Top rung — `spineboy` (2 skeletons: `ess`, `pro`)
1108
-
1109
- | Cat | Requirement |
1110
- | --- | --- |
1111
- | (a) | **IK constraints** — 🔴 first appearance: 7, of which 2 are multi-bone (two-bone IK) |
1112
- | (a) | **IK timelines** — 29 keys, 2-channel curves (mix, softness) |
1113
- | (a) | **Events** — 🔴 first appearance: the skeleton-level `events` block *and* the `events` timeline (5 keys) |
1114
- | (a) | **Bounding-box attachments** — 🔴 first appearance (1, in both `ess` and `pro`) |
1115
- | (a) | **Clipping attachment** — 🔴 first appearance (1, `pro` only) |
1116
- | (a) | **Unweighted meshes** — 🔴 first appearance (2 of 12 in `pro`) |
1117
- | (a) | Multi-page atlas handling; 4-channel (`rgba`, length-16) bezier curves — rigc already emits these correctly |
1118
- | (a) | Scale: 67 bones, 52 slots, 11 animations, 80 attachments |
1119
- | (b) | Event definitions and event tracks in the motion spec; bounding-box and clipping attachment declarations |
1120
- | (c) | 🚫 **A11_NO_CLIPPING_ATTACHMENTS** fails — renderer-profile |
1121
- | (c) | 🚫 **A20_MESH_WEIGHTS_COHERENT** fails on the 2 unweighted meshes — renderer-profile |
1122
- | (c) | 🚨 **A13_MESH_BUDGET** fails twice over: 9 mesh slots (budget 4) and two meshes at 95 / 101 triangles (budget 80) |
1123
- | (c) | Extend `walkTimelines` to visit `ik`, `transform`, and `events` — otherwise A05 silently stops guarding this rung's curves |
1124
- | (d) | none |
1125
-
1126
- ### 4.2 First appearance of each named feature
1127
-
1128
- | Feature | First rung | Where |
1129
- | --- | --- | --- |
1130
- | Packed / rotated atlas | **1** | `1-weight-and-mass` (`rotate: 90`) |
1131
- | Slot blend modes | **2** | `2-the-12-principles` (additive + multiply) |
1132
- | Bone `inherit` ≠ Normal | **2** | `2-the-12-principles` (`noRotationOrReflection`) |
1133
- | **drawOrder** | **5** | `5-squash-and-stretch` (2 keys) |
1134
- | **Transform constraints** | **6** | `6-arcs` (4, static) |
1135
- | **Weighted meshes** | **6** | `6-arcs` (2 meshes, 44 verts) |
1136
- | **Physics** | **7** | `7-anticipation` (20 constraints + 6 timeline kinds) |
1137
- | **Deform** | **7** | `7-anticipation` (1 key) |
1138
- | Keyed transform timeline | **7** | `7-anticipation` (11 keys) |
1139
- | **IK** | **spineboy** | `spineboy-pro` (7, incl. 2 multi-bone) |
1140
- | **Clipping** | **spineboy** | `spineboy-pro` (1) |
1141
- | Bounding box | **spineboy** | `spineboy-ess` and `-pro` (1 each) |
1142
- | Events | **spineboy** | both spineboy files (1 def, 5 keys) |
1143
- | Unweighted meshes | **spineboy** | `spineboy-pro` (2) |
1144
- | **Skins** | **never** | 🔵 **no example in the ladder uses a non-default skin.** Every one of the 12 files has exactly one skin, named `default`. Named skins, per-skin bones, and per-skin constraint lists are **not on this ladder at all** and were scheduled by some other justification: they landed with issues #2 and #7, off the ladder, and no rung's status depends on them. |
1145
- | Path constraints, sliders, sequences, linked meshes, dark colour, `drawOrderFolder` | **never** | absent from the entire corpus |
1146
-
1147
- ### 4.3 (c) The validator changes, consolidated
1148
-
1149
- The single structural change: **split the assertion set into profiles.** Concretely —
1150
-
1151
- 1. **`--profile spine`** (spec-only): A00–A05, A07, A09, A10, A16 (widened to accept
1152
- `4.3.75-beta`-style suffixes), A17, A18, A22, A23. These stay on for every rung.
1153
- 2. **`--profile spine-html`** (today's behaviour = spec + renderer policy): adds A06's PMA/rotation/
1154
- full-page clauses, A08/A27's name-identity policy, A11, A12, A13, A14, A19, A20.
1155
- 3. **Archetype assertions** (A15, A21, A24–A26, A28–A30) already SKIP without `RigInfo`
1156
- (`validate.ts:47-55`, `:808`) — no change; they simply never run on example data.
1157
- 4. **Widen `walkTimelines`** (`validate.ts:1159-1199`) to `ik`, `transform`, `path`, `slider`,
1158
- `drawOrder`, `drawOrderFolder`, `events`, with channel tables for each (§1.8 has the numbers).
1159
- Do this **before** the corresponding emitters land, not after — an unwalked group is a check that
1160
- silently reports green.
1161
- 5. Keep A05's "unchecked timeline — extend the validator" fail-closed behaviour. It is the reason
1162
- this document could enumerate the gap at all.
1163
-
1164
- ### 4.4 (d) What JSON cannot express
1165
-
1166
- Only two things, both editor-affordance data with **zero rendering effect** (§1.11):
1167
-
1168
- - **`bone.visible`** — read by `SkeletonBinary.ts:126`, not by `SkeletonJson`.
1169
- - **`skin.color`** — read by `SkeletonBinary.ts:448`, not by `SkeletonJson`.
1170
-
1171
- Neither appears in the ladder corpus (§3.0) and neither affects a rendered frame. **There is no
1172
- rendering-relevant feature that the binary format can express and JSON cannot**, so "we only emit
1173
- JSON" costs the benchmark nothing.
1174
-
1175
- One near-miss worth naming: the atlas page **`scale:`** line is emitted by the Spine packer and read
1176
- by *nobody* in spine-ts (§1.10). Reproducing it byte-for-byte is possible; reproducing its *effect*
1177
- is not, because there is no effect to reproduce in this runtime.
1178
-
1179
- ---
1180
-
1181
- ## Things I could not verify
355
+ editor-affordance data with zero rendering effect: there is no *rendering-relevant* feature that
356
+ binary can express and JSON cannot.
1182
357
 
1183
- 1. **PNG-level facts.** `count_features.ts` reads only `.json` and `.atlas`, so
1184
- `A19_OVERLAY_PNGS_HAVE_ALPHA` and `A06`'s "declared `size:` matches the file" were not evaluated
1185
- against the corpus. `fetch-examples` does download the pages, and `src/png.ts` already measures
1186
- PNG headers, so this is cheap to add.
1187
- 2. **`A14_NO_FULL_FRAME_MESH`** — needs `skeleton.width/height` compared against each mesh's
1188
- `width`/`height`; not computed per file.
1189
- 3. **Binary `.skel` behaviour** was read from source only; I did not parse an actual `.skel`.
1190
- 4. **`spineboy-run.atlas`'s ten identical regions** — the file matches upstream byte length, so it is
1191
- genuinely what the repo ships, but *why* it is shaped that way (a broken sequence export?) is
1192
- unexplained. It is unreferenced by both spineboy skeletons.
1193
- 5. **Whether the Spine editor itself would round-trip a rigc file.** Esoteric documents JSON import
1194
- (§"On third-party tools"), but nothing here tested it; the round-trip proof in this project is
1195
- spine-core-only.
1196
- 6. **Collection ran against a rate-limited GitHub REST API**, so some directory listings came from an
1197
- HTML fallback rather than the contents API. File contents came from `raw.githubusercontent.com`
1198
- and byte lengths were spot-checked against upstream `Content-Length`. `fetch-examples.sh` uses the
1199
- contents API and honours `GITHUB_TOKEN`; export one if a re-run hits the 60/hour limit.
1200
- 7. **Numbers here are a snapshot of branch `4.3`.** Re-run `bun run bench:usage` after a
1201
- `fetch-examples` to confirm the corpus has not moved before relying on a count.