spine-rigc 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1109 @@
1
+ # Spine 4.3 export-format surface vs. rigc coverage vs. the example ladder
2
+
3
+ Research note, 2026-08-22. A survey, not a plan: it establishes what the 4.3 export format can hold,
4
+ what rigc emits today, and what the official example projects actually use β€” so the gap list is
5
+ ordered by what the benchmark ladder needs rather than by the spec's alphabet.
6
+
7
+ Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
8
+ ([`bench/count_features.ts`](../bench/count_features.ts) β†’ [`feature_matrix.csv`](feature_matrix.csv),
9
+ [`feature_matrix.json`](feature_matrix.json)).
10
+
11
+ > πŸ”Ό **This note is dated and does not move; the measurements below are the corpus as it
12
+ > stood on 2026-08-22 and stay as written. Its statements about what rigc *does* have gone
13
+ > stale in five places, and the live ledger is [LADDER.md](LADDER.md):**
14
+ >
15
+ > - **B2 is fixed** β€” A16 accepts `4.3`, `4.3.N` and `4.3.N-<suffix>`, so all twelve example
16
+ > exports pass it.
17
+ > - **The profile split exists** β€” `--profile spine | spine-html`, default `spine-html`, so the
18
+ > nine renderer-policy assertions of Β§2.2 no longer apply to foreign data. That closes the
19
+ > validator half of B3; the emitter half (no packer, no atlas importer) is untouched.
20
+ > - **`walkTimelines` reaches all eleven groups** β€” Β§2.3 item 1 and Β§4.3 item 4 are done, so
21
+ > A05 is no longer blind to `ik` / `transform` / `path` / `slider` / `drawOrder` /
22
+ > `drawOrderFolder` / `events`.
23
+ > - **B1 is closed (2026-08-22)** β€” the bone tree is no longer code. `src/archetype.ts` is gone
24
+ > and the skeleton is a **rig spec** (`spec: "rigc-rig/1"`, `src/rig.ts`): bones, slots, skins,
25
+ > attachments, the 4.3 typed constraint array and the invariants, all as data. So Β§2.4's
26
+ > "the bone tree is code, not data", Β§4.0's B1 row and executive-summary item 7 describe a
27
+ > state that no longer exists. Rung 3 transcribed into that format scores 1.000 on every
28
+ > measure of every section β€” figures in [LADDER.md](LADDER.md).
29
+ > - Since that change rigc also emits **ik and transform constraints**, the **single-axis bone
30
+ > timelines** (`translatex`/`translatey`/`scalex`/`scaley`/`shear`/`shearx`/`sheary`), bone
31
+ > `length`/`scale`/`shear`/`inherit`/`skin`/`color` setup fields, slot `dark`/`blend`, region
32
+ > `path`/`scaleX`/`scaleY`/`color`, mesh `path`/`edges`/`color`, and the header's `fps` /
33
+ > `referenceScale` / `images`. Part 2's coverage tables predate all of it.
34
+ >
35
+ > **The measurements of the CORPUS (Part 3) have not moved and stay as written.**
36
+ >
37
+ > πŸ”’ **Redacted for publication (2026-08-23), and nothing but names.** The note was written
38
+ > inside the sandbox of the project rigc was split out of, and it enumerated that project's
39
+ > archetype names, bone tree and slot table. Those are the consumer's, not this
40
+ > repository's, so they are replaced by the SHAPE they stood for β€” counts, parentage,
41
+ > structure β€” which is what every argument here actually rests on: Β§2.4's point is that
42
+ > eighteen bones were hard-coded and *no example fits them*, and that survives the names
43
+ > going. Where a number was measured it is still the measured number.
44
+
45
+ ---
46
+
47
+ ## Executive summary (10 lines)
48
+
49
+ 1. **The de facto 4.3 spec is `SkeletonJson.ts`, and it has no schema** β€” every field passes through
50
+ `getValue(map, key, default)` (`SkeletonJson.ts:1404-1406`), so anything unknown is ignored and
51
+ anything missing silently becomes a default. There is no version gate in the JSON path.
52
+ 2. **The official JSON doc is still 3.8.24** (verified today, one version token on the page) and it
53
+ documents a format shape that 4.3 *no longer reads*: top-level `"ik"`/`"transform"`/`"path"`
54
+ arrays, bone `"transform"`, `"draworder"` all-lowercase. Where doc and parser disagree, **the
55
+ parser wins**; writing to the doc produces a file that loads clean and is wrong.
56
+ 3. **The atlas and binary docs, by contrast, are current 4.x** β€” the atlas page documents
57
+ `bounds:`/`offsets:`/`pma:`, which is the compact form the 4.3 reader prefers.
58
+ 4. **4.3 is the biggest format break of the 4.x line**, and I verified the transition points against
59
+ the 4.0/4.1/4.2 parsers: 4.1 added `sequence`, 4.2 added `physics` + `referenceScale` and renamed
60
+ bone `transform`β†’`inherit`, 4.3 collapsed all constraints into one `constraints[]` array with a
61
+ `type` discriminator, added the `slider` constraint, `drawOrderFolder`, bone icons, and rebuilt
62
+ the transform constraint around `source` + `properties`.
63
+ 5. The full 4.3 JSON surface is **7 attachment types, 5 constraint types, 37 timeline kinds,
64
+ 3 curve encodings** β€” enumerated with line numbers in Part 1.
65
+ 6. **rigc emits a narrow slice of it**: 2 of 7 attachment types (region, weighted mesh), 1 of 5
66
+ constraint types (physics), and **7 of 37 timeline kinds** (`rotate`/`translate`/`scale` on bones,
67
+ `attachment`/`rgba` on slots, `mix`/`reset` on physics). One skin, named `default`. No IK, no
68
+ transform constraint, no path, no slider, no deform, no drawOrder, no events, no sequences.
69
+ 7. rigc's **input model is narrower than its emitter**: the bone tree is *code*
70
+ (`src/archetype.ts`, three hard-coded archetypes), not data. That is the single largest blocker
71
+ for the example ladder β€” **no example project fits any of the three.**
72
+ 8. rigc's 31 assertions split into two kinds that must be separated before the ladder:
73
+ **Spine-validity rules** (A00–A05, A08, A17, A27 β€” these catch real silent corruption) and
74
+ **renderer/canvas-profile rules for spine-html** (A06 no-rotate/no-PMA, A11 no clipping, A12 no
75
+ dark colour, A13 ≀4 mesh slots ≀80 tris, A14 no full-frame mesh, A19 alpha, A20 meshes must be
76
+ weighted). The second group is a *policy*, not a spec, and must become a switch.
77
+ 9. The validator is **structurally blind** to whole timeline groups: `walkTimelines`
78
+ (`src/validate.ts:1159-1199`) only descends `bones`/`slots`/`physics`/`attachments`. An `ik`,
79
+ `transform`, `path`, `slider`, `drawOrder`, or `events` block would pass A05 unexamined.
80
+ 10. **The ladder uses far less of the format than the format holds** (12 skeleton files measured,
81
+ all `"4.3.75-beta"`, all flat-`constraints` shape): **no example uses a non-default skin**, path
82
+ constraint, slider, sequence, linked mesh, or dark colour. First appearances that matter:
83
+ drawOrder at rung 5, transform constraints + weighted meshes at rung 6, physics + deform at
84
+ rung 7, IK + clipping + events + bounding box + unweighted meshes only at spineboy. Three
85
+ blockers precede rung 1 β€” the bone tree being code, `A16`'s regex rejecting `4.3.75-beta`, and
86
+ the one-part-per-page atlas model against nine packed atlases. **Nothing was implemented *at the
87
+ time of writing*; see the banner above for what has been since.**
88
+
89
+ **Prior work this document extends, not repeats:** an earlier round of design notes covering parse
90
+ coverage (including the finding that the official JSON documentation is still 3.8), the round-trip
91
+ probe and the **six silent-failure cases** it measured β€” referred to below as cases 6a–6h β€” and a
92
+ coverage matrix of what one editor export actually contained. Those notes are design records held
93
+ by the project rigc was split out of, not files in this repository; every finding this document
94
+ leans on is restated here rather than cited. The wider question they did not ask is the one below:
95
+ what can the format hold at all, and what does the public benchmark corpus actually use.
96
+
97
+ ---
98
+
99
+ ## Sources
100
+
101
+ | # | Source | Lines | Version |
102
+ | --- | --- | ---: | --- |
103
+ | 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) |
104
+ | S2 | [`spine-core/src/SkeletonBinary.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/SkeletonBinary.ts) | 1455 | idem |
105
+ | S3 | [`spine-core/src/TextureAtlas.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/TextureAtlas.ts) | 274 | idem |
106
+ | S4 | [`spine-core/src/Animation.ts`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/spine-ts/spine-core/src/Animation.ts) | 2560 | idem |
107
+ | S5 | `spine-core/src/` β€” `BoneData.ts`, `SlotData.ts`, `ConstraintData.ts`, `PathConstraintData.ts`, `attachments/Sequence.ts`, `Skin.ts`, `SkeletonData.ts`, `Utils.ts` | β€” | idem |
108
+ | S6 | `SkeletonJson.ts` on branches `4.0`, `4.1`, `4.2` | β€” | for the format-diff table below |
109
+ | S7 | https://esotericsoftware.com/spine-json-format | β€” | **3.8.24** |
110
+ | S8 | https://esotericsoftware.com/spine-binary-format | β€” | no version token on page |
111
+ | S9 | https://esotericsoftware.com/spine-atlas-format | β€” | no version token; content is 4.x |
112
+ | S10 | https://esotericsoftware.com/spine-loading-skeleton-data | β€” | β€” |
113
+ | S11 | [`spine-runtimes/CHANGELOG.md`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/CHANGELOG.md) | 3164 | β€” |
114
+ | S12 | [`spine-runtimes/README.md`](https://github.com/EsotericSoftware/spine-runtimes/blob/4.3/README.md) | β€” | β€” |
115
+ | S13 | this repository | β€” | v0.1.0; `src/*.ts`, `cli.ts`, `selftest.ts`, `README.md`, `NOTICE.md` |
116
+ | S14 | the nine example projects under `examples/` (`bun run fetch-examples`) | β€” | all export `"spine": "4.3.75-beta"` |
117
+
118
+ **Citation convention.** Bare `:NNN` line numbers are into the branch-`4.3` TypeScript sources above,
119
+ which are also what `node_modules/@esotericsoftware/spine-core/src/<File>.ts` would hold β€”
120
+ ⚠️ the published npm package ships **`dist/` only**, so the local copy to read is
121
+ `node_modules/@esotericsoftware/spine-core/dist/<File>.js`, whose line numbers differ. The two were
122
+ reconciled before publication: the parser's full `case` label set is identical in both.
123
+
124
+ **Marker convention used in Part 1:** πŸ“˜ = the field is described on an official Esoteric docs page.
125
+ πŸ” = source-only; the parser reads it but no public doc page describes it.
126
+
127
+ ### Verified doc-status findings
128
+
129
+ - **The JSON format page documents 3.8.24.** The only version token anywhere in the page body is
130
+ `"spine" : "3.8.24"` inside its skeleton example (S7). Plan 04 Β§1-0 recorded this on 2026-08-21;
131
+ **it is still true on 2026-08-22.** The page also still shows the 3.8-shaped
132
+ `"ik" : [ { "name" : "left leg" …` top-level array and a `"draworder" : { … }` key spelled
133
+ all-lowercase β€” the 4.3 parser reads `map.drawOrder`, camel-cased (`SkeletonJson.ts:1209`).
134
+ - **The binary page carries no version token at all** and is otherwise structured like the JSON page.
135
+ - **The atlas page is current.** It documents `bounds:` / `offsets:` (the 4.x compact form), `pma:`,
136
+ `index:`, `split:`, `pad:`, and `rotate:` as "`true` … or a number representing degrees from 0 to
137
+ 360" β€” which is exactly what `TextureAtlas.ts:91-97` implements. **One gap:** the atlas doc does not
138
+ mention the page-level `scale:` line, yet every one of the nine official example atlases emits it
139
+ (Part 3) and the spine-ts reader silently discards it. Docs and parser are both incomplete here;
140
+ the *packer* is the only authority.
141
+ - **The CHANGELOG is not a format ledger for 4.x.** It records per-runtime API churn. The 4.0β†’4.3
142
+ JSON shape changes are not enumerated anywhere public; I derived them from the four parsers (S6),
143
+ table below.
144
+
145
+ ### On third-party tools writing these formats
146
+
147
+ Esoteric does not publish a prohibition; the docs invite it, in three places:
148
+
149
+ - JSON format page (S7): *"Also, Spine can import data in this format, allowing interoperability with
150
+ other tools."* β€” https://esotericsoftware.com/spine-json-format
151
+ - Loading guide (S10): *"Other tools can also be used to pack a texture atlas in the Spine atlas
152
+ format, such as Texture Packer Pro using the "libgdx" atlas format."* β€”
153
+ https://esotericsoftware.com/spine-loading-skeleton-data
154
+ - Esoteric's own `spine-scripts` repo README: *"In addition to exporting images, some scripts also
155
+ write JSON data which can be imported into Spine."* β€”
156
+ https://github.com/EsotericSoftware/spine-scripts
157
+
158
+ What *is* stated as policy is a **version-lockstep expectation**, not a format restriction β€”
159
+ `spine-runtimes/README.md` Β§Versioning (S12): *"It is highly suggested to freeze the Spine editor
160
+ version to match the Spine Runtimes source being used and to update them in lock step."* Note the
161
+ spine-ts **JSON** path does **not** enforce this: `SkeletonJson` stores `skeletonData.version` and
162
+ never compares it (`SkeletonJson.ts:78`). Some other-language runtimes do gate on it (CHANGELOG
163
+ line 1678: *"Parsing skeleton .JSON and .skel files will report an error if the skeleton version does
164
+ not match the runtime version"*), so a wrong `skeleton.spine` string is portable-fragile even though
165
+ spine-ts tolerates it. rigc's A16 already pins it to `4.3.x`.
166
+
167
+ Separately, the licence chain matters for the ladder and is already recorded in rigc's `NOTICE.md`:
168
+ rigc links spine-core, spine-core is under the Spine Runtimes License Agreement, and that agreement
169
+ requires each user of a product built on it to hold a Spine Editor licence. Reproducing the example
170
+ projects does not change that; see the per-example licence findings in Part 3.
171
+
172
+ ---
173
+
174
+ ## Format-change timeline, verified from the four parsers (S1, S6)
175
+
176
+ | Change | 4.0 | 4.1 | 4.2 | 4.3 |
177
+ | --- | --- | --- | --- | --- |
178
+ | Constraints location | `root.ik` / `root.transform` / `root.path` arrays | same | same **+ `root.physics`** | **single `root.constraints[]`, `type` discriminator** |
179
+ | Bone inheritance field | `"transform"` | `"transform"` | **`"inherit"`** | `"inherit"` |
180
+ | `sequence` on region/mesh | βœ— | **βœ“** | βœ“ | βœ“ |
181
+ | `skeleton.referenceScale` | βœ— | βœ— | **βœ“** | βœ“ |
182
+ | `physics` constraint + timelines | βœ— | βœ— | **βœ“** | βœ“ |
183
+ | `slider` constraint + timelines | βœ— | βœ— | βœ— | **βœ“** |
184
+ | `drawOrderFolder` timeline | βœ— | βœ— | βœ— | **βœ“** |
185
+ | Bone `icon`/`iconSize`/`iconRotation` (JSON) | βœ— | βœ— | βœ— | **βœ“** |
186
+ | Transform constraint model | `target` + `local`/`relative` + `offsetRotation` + mix\* | same | same | **`source` + `properties{from→to}` + `localSource`/`localTarget`/`additive`/`clamp`** |
187
+ | IK `uniform` bool | βœ“ | βœ“ | βœ“ | **replaced by `scaleY` (`ScaleYMode`)** |
188
+
189
+ Evidence: `root.ik` present in 4.0/4.1/4.2 and absent in 4.3; `root.constraints` absent in
190
+ 4.0/4.1/4.2 and present in 4.3; `uniform` at `4.2 SkeletonJson.ts:161`, gone in 4.3; `target` at
191
+ `4.2:182,222` vs `source` at `4.3:188`.
192
+
193
+ β‡’ **This is why case 6a matters so much for the ladder.** Any example project exported by a
194
+ pre-4.3 editor carries the *legacy* shape, and the 4.3 parser drops those constraints on the floor
195
+ with no error. Part 3 measures which shape each example actually ships.
196
+
197
+ ---
198
+
199
+ ## Part 1 β€” the full 4.3 JSON surface
200
+
201
+ All line numbers refer to **S1**, `SkeletonJson.ts` (branch `4.3`, 1406 lines), unless prefixed.
202
+ Everything reaches the parser through `getValue(map, property, defaultValue)` at `:1404-1406`, so
203
+ **"required" below means "the parser dereferences it unconditionally and produces `NaN`/`undefined`
204
+ if absent"** β€” never "the parser complains".
205
+
206
+ ### 1.1 Skeleton header β€” `root.skeleton` (`:75-87`)
207
+
208
+ | Field | Default | Notes | Doc |
209
+ | --- | --- | --- | --- |
210
+ | `hash` | `undefined` | opaque; tools use it for change detection | πŸ“˜ |
211
+ | `spine` | `undefined` | stored as `skeletonData.version`, **never compared** in spine-ts JSON | πŸ“˜ |
212
+ | `x`, `y`, `width`, `height` | `undefined` | the setup-pose bounding box | πŸ“˜ |
213
+ | `referenceScale` | `100` (Γ— `scale`) | **4.2+**; drives runtime physics/scale reference | πŸ” |
214
+ | `fps` | `undefined` β†’ `SkeletonData.fps` stays `30` | nonessential | πŸ“˜ |
215
+ | `images` | `null` | nonessential | πŸ“˜ |
216
+ | `audio` | `null` | nonessential | πŸ“˜ |
217
+
218
+ The whole `skeleton` block is optional (`if (skeletonMap)` at `:76`).
219
+
220
+ ### 1.2 Bones β€” `root.bones[]` (`:90-118`)
221
+
222
+ | Field | Default | Notes | Doc |
223
+ | --- | --- | --- | --- |
224
+ | `name` | β€” | required in practice (`new BoneData(…, boneMap.name, …)`, `:97`) | πŸ“˜ |
225
+ | `parent` | `null` | resolved by name; **must be declared earlier in the array** | πŸ“˜ |
226
+ | `length` | `0` (Γ— scale) | | πŸ“˜ |
227
+ | `x`, `y` | `0` (Γ— scale) | local to parent | πŸ“˜ |
228
+ | `rotation` | `0` | degrees, CCW, y-up | πŸ“˜ |
229
+ | `scaleX`, `scaleY` | `1` | | πŸ“˜ |
230
+ | `shearX`, `shearY` | `0` | | πŸ“˜ |
231
+ | `inherit` | `"Normal"` | enum `Normal \| OnlyTranslation \| NoRotationOrReflection \| NoScale \| NoScaleOrReflection` (`BoneData.ts:80`). Resolved by `Utils.enumValue` which upper-cases the first letter (`Utils.ts:392-394`), so `"noScale"` and `"NoScale"` both work. **4.2+ name; 4.0/4.1 called it `transform`** | πŸ” |
232
+ | `skin` | `false` | β†’ `data.skinRequired` | πŸ“˜ (as "skin") |
233
+ | `color` | none | hex string `rrggbbaa`, nonessential | πŸ“˜ |
234
+ | `icon` | `undefined` | **4.3, editor-only affordance** | πŸ” |
235
+ | `iconSize` | `1` | 4.3 | πŸ” |
236
+ | `iconRotation` | `0` | 4.3 | πŸ” |
237
+ | ~~`visible`~~ | β€” | **NOT read from JSON.** `SkeletonBinary.ts:126` reads it (nonessential); the JSON parser has no equivalent. See Β§1.10. | πŸ” |
238
+
239
+ ### 1.3 Slots β€” `root.slots[]` (`:121-141`)
240
+
241
+ | Field | Default | Notes | Doc |
242
+ | --- | --- | --- | --- |
243
+ | `name` | β€” | required | πŸ“˜ |
244
+ | `bone` | β€” | **required**; a miss throws `Couldn't find bone … for slot …` (`:127`) | πŸ“˜ |
245
+ | `color` | white | `rrggbbaa` | πŸ“˜ |
246
+ | `dark` | none | two-colour tint; only set when present, and `darkColor` stays `null` otherwise (`:133-134`) | πŸ“˜ |
247
+ | `attachment` | `null` | setup-pose attachment name | πŸ“˜ |
248
+ | `blend` | `"normal"` | enum `Normal \| Additive \| Multiply \| Screen` (`SlotData.ts:64`) | πŸ“˜ |
249
+ | `visible` | `true` | 4.3, nonessential-ish; **JSON reads this one** (`:138`) | πŸ” |
250
+
251
+ **Draw order is the array order of `slots`.** There is no separate setup draw-order field.
252
+
253
+ ### 1.4 Constraints β€” `root.constraints[]` (`:144-369`) β€” **4.3 shape**
254
+
255
+ Common to every entry: `name`, `type`, `skin` (default `false` β†’ `skinRequired`, `:147`).
256
+ `type` is read with `getValue(constraintMap, "type", false)`, so **an entry with no `type` matches
257
+ no case and is silently dropped** (`:148-367`, no `default:` branch).
258
+
259
+ **`type: "ik"`** (`:149-176`) πŸ“˜ (3.8 shape only)
260
+
261
+ | Field | Default |
262
+ | --- | --- |
263
+ | `bones[]` | required, β‰₯1, resolved by name (throws on miss) |
264
+ | `target` | required (throws on miss) |
265
+ | `scaleY` | absent β†’ `ScaleYMode.None`; enum `None \| Uniform \| Volume` (`ConstraintData.ts:50`). **4.3 replacement for 4.2's `uniform: bool`** |
266
+ | `mix` | `1` |
267
+ | `softness` | `0` (Γ— scale) |
268
+ | `bendPositive` | `true` β†’ `bendDirection = Β±1` |
269
+ | `compress` | `false` |
270
+ | `stretch` | `false` |
271
+
272
+ **`type: "transform"`** (`:177-268`) πŸ” β€” completely rebuilt in 4.3
273
+
274
+ | Field | Default |
275
+ | --- | --- |
276
+ | `bones[]` | required |
277
+ | `source` | required (4.2 called this `target`) |
278
+ | `localSource`, `localTarget`, `additive`, `clamp` | `false` |
279
+ | `properties` | `{}` β€” a map `fromName β†’ { offset, to: { toName β†’ { offset, max, scale } } }`. `fromName`/`toName` ∈ `rotate \| x \| y \| scaleX \| scaleY \| shearY`; anything else **throws** (`:241`, `:521`). `x`/`y` offsets are scaled (`propertyScale`, `:526-532`) |
280
+ | `rotation`, `x`, `y`, `scaleX`, `scaleY`, `shearY` | `0` β€” the constraint's offsets array |
281
+ | `mixRotate`, `mixX`, `mixScaleX`, `mixShearY` | `1`; `mixY` defaults to `mixX`, `mixScaleY` to `mixScaleX`. **Each mix is only read if the matching `to` property was declared** (`:259-264`) |
282
+
283
+ **`type: "path"`** (`:269-300`) πŸ“˜ (3.8 shape)
284
+
285
+ | Field | Default |
286
+ | --- | --- |
287
+ | `bones[]`, `slot` | required |
288
+ | `positionMode` | `"Percent"` β€” `Fixed \| Percent` (`PathConstraintData.ts:77`) |
289
+ | `spacingMode` | `"Length"` β€” `Length \| Fixed \| Percent \| Proportional` (`:82`) |
290
+ | `rotateMode` | `"Tangent"` β€” `Tangent \| Chain \| ChainScale` (`:87`) |
291
+ | `rotation` | `0` β†’ `offsetRotation` |
292
+ | `position` | `0`; Γ— scale iff `positionMode == Fixed` |
293
+ | `spacing` | `0`; Γ— scale iff `spacingMode ∈ {Length, Fixed}` |
294
+ | `mixRotate`, `mixX` | `1`; `mixY` defaults to `mixX` |
295
+
296
+ **`type: "physics"`** (`:301-339`) πŸ” β€” 4.2+
297
+
298
+ | Field | Default |
299
+ | --- | --- |
300
+ | `bone` | required (throws) |
301
+ | `x`, `y`, `rotate`, `scaleX`, `shearX` | `0` β€” **the components. All zero = a constraint that parses and does nothing** |
302
+ | `scaleY` | absent β†’ `ScaleYMode.None` (4.3) |
303
+ | `limit` | `5000` (Γ— scale) |
304
+ | `fps` | `60` β†’ `step = 1/fps` |
305
+ | `inertia` | `0.5` |
306
+ | `strength` | `100` |
307
+ | `damping` | `0.85` |
308
+ | `mass` | `1` β†’ stored as `massInverse = 1/mass` |
309
+ | `wind`, `gravity` | `0` |
310
+ | `mix` | `1` |
311
+ | `inertiaGlobal`, `strengthGlobal`, `dampingGlobal`, `massGlobal`, `windGlobal`, `gravityGlobal`, `mixGlobal` | `false` |
312
+
313
+ **`type: "slider"`** (`:340-366`) πŸ” β€” **new in 4.3, undocumented anywhere public**
314
+
315
+ | Field | Default |
316
+ | --- | --- |
317
+ | `additive`, `loop` | `false` |
318
+ | `mix` | `1` |
319
+ | `bone` | optional. **Presence switches the whole model**: with a bone it is a property-driven slider, without one it is a time slider (`time`, default `0`, `:361`) |
320
+ | `property` | required when `bone` is set; same six `from` names as the transform constraint |
321
+ | `from` | `0` (Γ— propertyScale) β†’ `data.property.offset` |
322
+ | `to` | `0` β†’ `data.offset` |
323
+ | `scale` | `1` Γ· propertyScale |
324
+ | `max` | `0` |
325
+ | `local` | `false` |
326
+ | `animation` | resolved in a **second pass over `root.constraints`** after animations are read (`:495-507`); a miss throws `Slider animation not found` |
327
+
328
+ ### 1.5 Skins β€” `root.skins[]` (`:372-443`)
329
+
330
+ | Field | Default | Notes |
331
+ | --- | --- | --- |
332
+ | `name` | β€” | the skin named `"default"` becomes `skeletonData.defaultSkin` (`:441`) |
333
+ | `bones[]` | none | bone names this skin activates (`:377-384`) |
334
+ | `ik[]`, `transform[]`, `path[]`, `physics[]`, `slider[]` | none | **constraint names, still split per type inside a skin** even though the top-level array was unified (`:386-429`) |
335
+ | `attachments` | `{}` | `slotName β†’ { placeholderName β†’ attachmentMap }` (`:431-439`) |
336
+ | ~~`color`~~ | β€” | **not read from JSON.** `Skin.color` exists with a default of `fe9e4fff` (`Skin.ts:71-72`) and only `SkeletonBinary.ts:448` sets it. |
337
+
338
+ The **placeholder** (the key) and the attachment's own `name` are different things: `name` defaults
339
+ to the placeholder (`:537`), and `path` defaults to `name` (`:541`, `:570`). Three-level indirection:
340
+ placeholder β†’ name β†’ path β†’ atlas region.
341
+
342
+ ### 1.6 Attachments (`readAttachment`, `:535-654`) β€” `type` defaults to `"region"` (`:539`)
343
+
344
+ | Type | Fields (default) | Line | Doc |
345
+ | --- | --- | --- | --- |
346
+ | `region` | `path`(=name), `sequence`(null), `x`(0Γ—s), `y`(0Γ—s), `scaleX`(1), `scaleY`(1), `rotation`(0), **`width`/`height` (no default β€” `map.width * scale`, `undefined` β†’ `NaN`)**, `color` | `:540-559` | πŸ“˜ |
347
+ | `boundingbox` | `vertexCount` (no default), `vertices`, `color` | `:560-567` | πŸ“˜ |
348
+ | `mesh` | `path`(=name), `sequence`, `color`, `width`(0), `height`(0), `uvs` (no default β€” **its length defines `worldVerticesLength`**), `triangles` (no default β€” `undefined` if missing), `vertices`, `edges`(null), `hull`(0, **stored Γ—2** as `hullLength`) | `:568-605` | πŸ“˜ |
349
+ | `linkedmesh` | same head, then `source` (required to make it linked), `slot`(null), `skin`(null), `timelines`(true). **A map with `type:"mesh"` and a `source` key is also a linked mesh** β€” the two cases share one branch (`:568-569`) and the `source` check at `:582` is what decides | `:568-605` | πŸ“˜ |
350
+ | `path` | `closed`(false), `constantSpeed`(true), `vertexCount` (no default), `vertices`, `lengths` (no default β€” `map.lengths.length` is dereferenced), `color` | `:606-623` | πŸ“˜ |
351
+ | `point` | `x`(0Γ—s), `y`(0Γ—s), `rotation`(0), `color` | `:624-634` | πŸ“˜ |
352
+ | `clipping` | `end`(null β†’ slot name), `convex`(false, **4.3**), `inverse`(false, **4.3**), `vertexCount`, `vertices`, `color` | `:635-651` | πŸ“˜ (convex/inverse πŸ”) |
353
+
354
+ **Any other `type` string returns `null`** (`:653`) β€” the attachment vanishes with no error.
355
+
356
+ **`sequence`** (`readSequence`, `:656-663`) πŸ” β€” region and mesh only:
357
+ `count` (0), `start` (1), `digits` (0), `setup` (0). Absent β†’ `new Sequence(1, false)`.
358
+
359
+ **Vertex encoding** (`readVertices`, `:666-693`) β€” **the highest-risk field in the format.**
360
+ There is no flag. If `vertices.length === verticesLength` (i.e. `uvs.length`, or `vertexCount<<1`)
361
+ it is read as **unweighted** x/y pairs; otherwise as the **weighted** run-length encoding
362
+ `boneCount, (boneIndex, bindX, bindY, weight) Γ— boneCount, …`. A coincidental length match reads
363
+ weight data as coordinates. Plan 04 Β§1-3 already recorded this; it is restated here because the
364
+ example corpus contains both encodings.
365
+
366
+ 🚨 The second risk in the same field is `boneIndex`: it is a position in the emitted bone array,
367
+ so the run means something different the moment the bone list changes, and nothing in the file
368
+ records what it used to mean. A rig spec therefore writes `weights` β€” the same data with the bones
369
+ **named** β€” and rigc encodes this run on emit. The raw form stays reachable behind
370
+ `"boneIndexing": "raw"` for transcribing an export verbatim. Issue #45.
371
+
372
+ ### 1.7 Events β€” `root.events` (object, not array) (`:469-484`)
373
+
374
+ `eventName β†’ { int (0), float (0), string (""), audio (null), volume, balance }`.
375
+ **`volume` and `balance` are only read when `audio` is set** (`:478-481`) β€” otherwise the setup values
376
+ stand. πŸ“˜
377
+
378
+ ### 1.8 Animation timelines β€” `root.animations[animName]` (`readAnimation`, `:696-1272`)
379
+
380
+ Top-level groups inside one animation: `slots`, `bones`, `ik`, `transform`, `path`, `physics`,
381
+ `slider`, `attachments`, `drawOrder`, `drawOrderFolder`, `events`, plus a nonessential `color`
382
+ (`:1268-1269`). Anything else is ignored.
383
+
384
+ | Group | Timeline | Value fields per key | Curve channels | Line | Doc |
385
+ | --- | --- | --- | --- | --- | --- |
386
+ | `slots.<slot>` | `attachment` | `name` (nullable) | **none** | `:713-721` | πŸ“˜ |
387
+ | | `rgba` | `color` (`rrggbbaa`) | 4 | `:722-751` | πŸ“˜ |
388
+ | | `rgb` | `color` (`rrggbb`) | 3 | `:752-780` | πŸ” |
389
+ | | `alpha` | `value` | 1 | `:781-784` | πŸ” |
390
+ | | `rgba2` | `light`, `dark` | 7 | `:785-821` | πŸ” |
391
+ | | `rgb2` | `light`, `dark` | 6 | `:822-857` | πŸ” |
392
+ | | *anything else* | β€” | β€” | **throws** `Invalid timeline type for a slot` (`:858-859`) | |
393
+ | `bones.<bone>` | `rotate` | `value` | 1 | `:878` | πŸ“˜ |
394
+ | | `translate` | `x`, `y` | 2 | `:879` | πŸ“˜ |
395
+ | | `translatex` / `translatey` | `value` | 1 | `:880-881` | πŸ” |
396
+ | | `scale` | `x`, `y` | 2 | `:882` | πŸ“˜ |
397
+ | | `scalex` / `scaley` | `value` | 1 | `:883-884` | πŸ” |
398
+ | | `shear` | `x`, `y` | 2 | `:885` | πŸ“˜ |
399
+ | | `shearx` / `sheary` | `value` | 1 | `:886-887` | πŸ” |
400
+ | | `inherit` | `inherit` (enum string) | **none** | `:888-896` | πŸ” |
401
+ | | *anything else* | β€” | β€” | **throws** `Invalid timeline type for a bone` (`:897-898`) | |
402
+ | `ik.<constraint>` | (one array, no sub-name) | `mix`(1), `softness`(0Γ—s), `bendPositive`(true), `compress`(false), `stretch`(false) | 2 (mix, softness) | `:906-945` | πŸ“˜ |
403
+ | `transform.<constraint>` | (one array) | `mixRotate`(1), `mixX`(1), `mixY`(=mixX), `mixScaleX`(1), `mixScaleY`(1), `mixShearY`(1) | 6 | `:948-999` | πŸ“˜ |
404
+ | `path.<constraint>` | `position` | `value` | 1 | `:1015-1019` | πŸ“˜ |
405
+ | | `spacing` | `value` | 1 | `:1020-1024` | πŸ“˜ |
406
+ | | `mix` | `mixRotate`, `mixX`, `mixY` | 3 | `:1025-1056` | πŸ“˜ |
407
+ | `physics.<constraint>` | `inertia`/`strength`/`damping`/`mass`/`wind`/`gravity` | `value` (default 0) | 1 each | `:1088-1093` | πŸ” |
408
+ | | `mix` | `value` (default **1**) | 1 | `:1094-1098` | πŸ” |
409
+ | | `reset` | *no value* β€” time only | **none** | `:1080-1086` | πŸ” |
410
+ | | *anything else* | β€” | β€” | silently `continue`d (`:1099`) β€” **no throw** | |
411
+ | `slider.<constraint>` | `time` | `value` (default 1) | 1 | `:1121` | πŸ” |
412
+ | | `mix` | `value` (default 1) | 1 | `:1122` | πŸ” |
413
+ | `attachments.<skin>.<slot>.<attachment>` | `deform` | `offset`(0), `vertices[]` | 1 | `:1149-1187` | πŸ“˜ |
414
+ | | `sequence` | `time`, `mode`(`"hold"`), `index`(0), `delay`(inherits previous) | **none** | `:1188-1201` | πŸ” |
415
+ | | *anything else* | β€” | β€” | silently ignored | |
416
+ | `drawOrder` | (array of keys) | `time`, `offsets[]` of `{slot, offset}`. **No `offsets` = reset to setup order** (`:1352-1353`) | **none** | `:1209-1217` | πŸ“˜ (spelled `draworder`) |
417
+ | `drawOrderFolder` | (array of folders) | `slots[]` (slot names in the folder), `keys[]` of the same `{time, offsets}` shape, resolved *within the folder* | **none** | `:1220-1239` | πŸ” **4.3 only** |
418
+ | `events` | (array) | `name` (required, throws on miss), `time`(0), `int`/`float`/`string` (default = event's setup), `volume`/`balance` **only when the event has an audio path** | **none** | `:1242-1261` | πŸ“˜ |
419
+
420
+ Important asymmetries worth writing down:
421
+
422
+ - **The physics group's constraint name may be the empty string** (`:1067`), which yields `index = -1`
423
+ and applies to *all* physics constraints. There is no documentation of this.
424
+ - **A `deform` key with no `vertices`** resets to the setup mesh (weighted β†’ zeros, unweighted β†’ the
425
+ base vertices) β€” `:1158-1160`.
426
+ - **`drawOrder` produces exactly one timeline for the whole animation**; `drawOrderFolder` produces
427
+ one per folder entry.
428
+ - **Empty timeline arrays**: bones `continue` on `frames === 0` (`:875`); ik/transform/path/physics/
429
+ slider `continue` when `[0]` is missing; **slots do not** β€” an empty `rgba` array reaches
430
+ `timelineMap[0]` and dereferences `keyMap.color` β†’ `TypeError`.
431
+
432
+ ### 1.9 Curve encoding (`readCurve`, `:1388-1401`; `readTimeline1/2`, `:1296-1346`)
433
+
434
+ Three encodings, on the **key that starts the interval** (`keyMap.curve`, never the destination key):
435
+
436
+ | Encoding | JSON | Meaning |
437
+ | --- | --- | --- |
438
+ | linear | `curve` absent | straight interpolation |
439
+ | stepped | `"curve": "stepped"` | `timeline.setStepped(frame)` (`:1391`) |
440
+ | bezier | `"curve": [ … ]` | array of **exactly 4 numbers per value channel**, concatenated in channel order |
441
+
442
+ Layout, exactly: for channel index `value`, `i = value << 2` and the four numbers are
443
+ `[cx1, cy1, cx2, cy2]` (`:1394-1398`). These are **absolute (time, value) control points**, not
444
+ normalised graph-view handles β€” `cy1`/`cy2` are multiplied by the timeline's `scale` factor, `cx1`/`cx2`
445
+ are not. So the array length per timeline is `4 Γ— channels` from the table in Β§1.8:
446
+ rotate 4, translate 8, scale 8, shear 8, rgb 12, rgba 16, rgb2 24, rgba2 28, ik 8, transform 24,
447
+ path mix 12, alpha/scalex/deform/physics/slider 4.
448
+
449
+ **A short array is the format's nastiest silent failure**: `curve[i+3]` is `undefined`, the product
450
+ is `NaN`, and nothing throws (case 6g). Timelines with no curve at all (`attachment`,
451
+ `inherit`, `sequence`, `drawOrder`, `events`, `physics reset`) ignore a `curve` key entirely.
452
+
453
+ The X axis is **seconds** in 4.x. It was frames in 3.8 β€” one more reason the 3.8 doc is actively
454
+ dangerous as a spec.
455
+
456
+ ### 1.10 Atlas text format (`TextureAtlas.ts`, S3)
457
+
458
+ Reader rules (`:194-227`): entries are `key: v1, v2, v3, v4` with **at most four values**
459
+ (`:224`); a line with no colon terminates the current block (`:214`). A **blank line closes a page
460
+ block** (`:119-121`). Page names are `line.trim()` (`:123`) but **region names are the raw line**
461
+ (`:131`) β€” leading whitespace becomes part of the name. Both traps were measured in the probe.
462
+
463
+ **Header entries before the first page are read and silently discarded** (`:106-111` β€” the comment
464
+ says so literally).
465
+
466
+ | Page field | Parsed into | Doc |
467
+ | --- | --- | --- |
468
+ | `size: w, h` | `page.width/height` (`:43-46`) β€” **used to compute every UV**, so a wrong value collapses all of them | πŸ“˜ |
469
+ | `format: …` | **parsed and thrown away** (`:47-49`, "we don't need format in WebGL") | πŸ“˜ |
470
+ | `filter: min, mag` | `minFilter`/`magFilter` (`:50-53`) | πŸ“˜ |
471
+ | `repeat: x\|y\|xy\|none` | `uWrap`/`vWrap` (`:54-57`) | πŸ“˜ |
472
+ | `pma: true\|false` | `page.pma` (`:58-60`) | πŸ“˜ |
473
+ | `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 |
474
+
475
+ | Region field | Parsed into | Doc |
476
+ | --- | --- | --- |
477
+ | `bounds: x, y, w, h` | 4.x compact form (`:71-76`) | πŸ“˜ |
478
+ | `offsets: ox, oy, ow, oh` | 4.x compact form (`:85-90`) | πŸ“˜ |
479
+ | `xy: x, y` | **deprecated** alias (`:63-66`) | πŸ“˜ |
480
+ | `size: w, h` | **deprecated** alias (`:67-70`) | πŸ“˜ |
481
+ | `offset: ox, oy` | **deprecated** (`:77-80`) | πŸ“˜ |
482
+ | `orig: w, h` | **deprecated** (`:81-84`) | πŸ“˜ |
483
+ | `rotate: true \| false \| <degrees>` | `true` β†’ 90; `false` β†’ 0; anything else `parseInt` (`:91-97`). **90 swaps width/height in the UV computation** (`:161-167`) | πŸ“˜ |
484
+ | `index: n` | frame index for sequential regions (`:98-100`) | πŸ“˜ |
485
+ | `split: l, r, t, b` | **no dedicated field.** Falls through to the generic bucket β†’ `region.names`/`region.values` (`:139-147`) | πŸ“˜ |
486
+ | `pad: l, r, t, b` | same generic bucket | πŸ“˜ |
487
+ | *any other key* | same generic bucket, `parseInt`-ed | πŸ” |
488
+
489
+ If `orig`/`offsets` never set an original size, it falls back to the packed size (`:149-152`).
490
+
491
+ ### 1.11 What the binary `.skel` adds or drops relative to JSON (S2)
492
+
493
+ Same feature set β€” same five constraint types (`SkeletonBinary.ts:1427-1431`), same attachment types,
494
+ same timeline catalogue (it imports the identical list, `:30`). Differences that matter to a JSON-only
495
+ emitter:
496
+
497
+ | Aspect | JSON | Binary |
498
+ | --- | --- | --- |
499
+ | Strings | inline | string table, index-referenced (`:93-100`) |
500
+ | Hash | string field | two int32s, joined as hex (`:76-78`) |
501
+ | Nonessential gate | per-field presence | **one boolean** (`:86`), then `fps`/`images`/`audio` (`:87-90`) |
502
+ | Bone `color`/`icon`/`iconSize`/`iconRotation` | JSON reads all four | nonessential block (`:121-126`) |
503
+ | **Bone `visible`** | ❌ **not readable from JSON** | `:126` |
504
+ | **Skin `color`** | ❌ **not readable from JSON** | `:448` |
505
+ | Slot `visible` | `:138` βœ“ | `:145` |
506
+ | Animation `color` | `:1268` βœ“ | `:1211` |
507
+ | Attachment colours | always read when present | only when nonessential (`:513`, `:600`, `:617`, `:630`) |
508
+
509
+ β‡’ **Two things are JSON-inexpressible in 4.3 spine-ts: `bone.visible` and `skin.color`.** Both are
510
+ editor-affordance data with zero rendering effect, so this costs the ladder nothing β€” but it is the
511
+ honest answer to Part 4(d): there is no *rendering-relevant* feature that binary can express and JSON
512
+ cannot.
513
+
514
+ ---
515
+
516
+ ## Part 2 β€” what rigc emits today
517
+
518
+ rigc v0.1.0, pinned to `@esotericsoftware/spine-core@4.3.13`. Line references in this Part are into
519
+ this repository (`src/`, `cli.ts`, `selftest.ts`) and were taken at commit `795e6f9`.
520
+
521
+ Legend: βœ… emits Β· 🟑 partial Β· ❌ not emitted Β· 🚫 deliberately excluded by a named assertion.
522
+
523
+ ### 2.1 Coverage against Part 1
524
+
525
+ **Header** (`compile.ts:634-646`)
526
+
527
+ | Part-1 row | rigc | Note |
528
+ | --- | --- | --- |
529
+ | `spine` | βœ… | constant `SPINE_VERSION = '4.3.13'` (`compile.ts:41`); A16 re-checks it is `4.3.x` |
530
+ | `x`, `y` | βœ… | always literal `0, 0` |
531
+ | `width`, `height` | βœ… | from `manifest.crop.w/h` |
532
+ | `hash` | ❌ | never emitted (harmless; the parser stores it and nothing reads it) |
533
+ | `fps`, `images`, `audio`, `referenceScale` | ❌ | |
534
+
535
+ **Bones** (`compile.ts:367-456`; type at `types.ts:285-292`)
536
+
537
+ | Part-1 row | rigc |
538
+ | --- | --- |
539
+ | `name`, `parent`, `x`, `y` | βœ… |
540
+ | `rotation` | βœ… β€” emitted only when non-zero; comes from `manifest.axis.deg` or a per-anchor facing angle, screenβ†’Spine converted (`transform.ts`) |
541
+ | `length`, `scaleX`, `scaleY`, `shearX`, `shearY`, `inherit`, `skin`, `color` | βœ… β€” copied from the rig spec when it gives them, omitted when it does not |
542
+ | `icon` | βœ… β€” copied verbatim, unchecked; it is the editor's vocabulary, not rigc's (issue #47) |
543
+ | `iconSize`, `iconRotation` | ❌ |
544
+ | bone `visible` | ❌ (JSON-inexpressible anyway, §1.10) |
545
+
546
+ **Slots** (`compile.ts:466-524`; type at `types.ts:294-299`)
547
+
548
+ | Part-1 row | rigc |
549
+ | --- | --- |
550
+ | `name`, `bone` | βœ… |
551
+ | `attachment` | βœ… β€” only when the motion spec's `setup` block names one; `null` means "show nothing" and the key is omitted |
552
+ | `color` | βœ… β€” from `setup.color`, hex-encoded (`compile.ts:63-66`) |
553
+ | `dark` | 🚫 **A12_NO_DARK_COLOR** (`validate.ts:250-262`) |
554
+ | `blend` | ❌ |
555
+ | `visible` | ❌ |
556
+ | draw order = slots array order | βœ…, and **A26_SLOT_DRAW_ORDER** pins it to the archetype's `slotOrder` table |
557
+
558
+ **Skins** β€” βœ… exactly one, hard-coded `{ name: 'default', attachments }` (`compile.ts:644`).
559
+ ❌ named skins, ❌ per-skin `bones`/constraint lists, ❌ `color` (JSON-inexpressible).
560
+
561
+ **Attachments**
562
+
563
+ | Part-1 type | rigc | Detail |
564
+ | --- | --- | --- |
565
+ | `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). ❌ `path` (region name == attachment name == PNG basename, enforced by **A08** + **A27**), ❌ `scaleX`/`scaleY`, ❌ `color`, ❌ `sequence` |
566
+ | `mesh` | 🟑 | emits `type`, `uvs`, `triangles`, `vertices` (**weighted encoding only**), `hull`, `width`, `height`. ❌ `edges`, ❌ `path`, ❌ `color`, ❌ `sequence`. Unweighted meshes are 🚫 **A20_MESH_WEIGHTS_COHERENT** (`validate.ts:430-433`) |
567
+ | `linkedmesh` | ❌ | |
568
+ | `boundingbox` | ❌ | |
569
+ | `path` | ❌ | |
570
+ | `point` | ❌ | |
571
+ | `clipping` | 🚫 | **A11_NO_CLIPPING_ATTACHMENTS** (`validate.ts:373-377`) |
572
+ | `sequence` block | ❌ | |
573
+
574
+ Mesh geometry is generated by exactly two procedural generators (`mesh.ts`): `buildRingMesh`
575
+ (three concentric rings + hub, outer two pinned) and `buildRibbonMesh` (a two-wide strip along a bone
576
+ chain). There is **no triangulator for arbitrary art** and no importer for editor-made meshes.
577
+
578
+ **Constraints** (`compile.ts:526-568`)
579
+
580
+ | Part-1 type | rigc |
581
+ | --- | --- |
582
+ | `physics` | βœ… β€” full field set: `bone`, the five components `x`/`y`/`rotate`/`scaleX`/`shearX`, and `inertia`/`strength`/`damping`/`mass`/`wind`/`gravity`/`mix`/`fps`/`limit`. Values equal to the parser default are **omitted** (`compile.ts:555-559`). ❌ `scaleY` (ScaleYMode), ❌ the seven `*Global` flags, ❌ `skin` |
583
+ | `ik` | ❌ |
584
+ | `transform` | ❌ |
585
+ | `path` | ❌ |
586
+ | `slider` | ❌ |
587
+ | 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 |
588
+
589
+ **Timelines** (`compile.ts:570-631`, `:877-936`, `:981-1041`)
590
+
591
+ | Part-1 group | rigc |
592
+ | --- | --- |
593
+ | `bones.rotate` / `translate` / `scale` | βœ… (`BONE_TRACKS`, `compile.ts:75-79`) |
594
+ | `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) |
595
+ | `slots.attachment` | βœ… β€” value `name`, nullable; easing is refused |
596
+ | `slots.rgba` | βœ… |
597
+ | `slots.rgb`, `alpha` | ❌ emitted (validator knows the channel counts) |
598
+ | `slots.rgba2`, `rgb2` | 🚫 **A12_NO_DARK_COLOR** (`validate.ts:257-261`) |
599
+ | `physics.mix`, `physics.reset` | βœ… (`PHYSICS_TRACKS`, `compile.ts:87-90`) |
600
+ | `physics.inertia/strength/damping/mass/wind/gravity` | ❌ |
601
+ | `ik`, `transform`, `path`, `slider` | ❌ |
602
+ | `attachments.<skin>.…deform` | ❌ (validator knows it: 1 channel, `validate.ts:104-107`) |
603
+ | `attachments.…sequence` | ❌ |
604
+ | `drawOrder`, `drawOrderFolder` | ❌ |
605
+ | `events` (+ the `root.events` block) | ❌ |
606
+ | animation `color` | ❌ |
607
+
608
+ **Curves** β€” βœ… all three encodings. Linear = no `curve` key; `"stepped"` passes through as the string
609
+ (`compile.ts:917-918`); bezier is computed from **named easings** (`motion.easings`, four normalised
610
+ handles) into **absolute (time, value) control points**, `bezierForChannel` (`compile.ts:121-135`),
611
+ emitted 4 numbers Γ— channel count in field order. This is the correct transform β€” writing the
612
+ normalised handles straight in is the silent bug that costs an authoring loop.
613
+
614
+ **Atlas** (`compile.ts:346-365`) β€” one part per page, no packer.
615
+
616
+ | Part-1 field | rigc |
617
+ | --- | --- |
618
+ | page name | βœ… (PNG path relative to the atlas file) |
619
+ | `size: w, h` | βœ… from PNG measurement (`png.ts`), re-checked against the file by **A06** |
620
+ | `filter: Linear, Linear` | βœ… hard-coded |
621
+ | `pma: false` | βœ… hard-coded; a `true` is 🚫 **A06** |
622
+ | `format:`, `repeat:` | ❌ (both optional; `format` is discarded by the reader anyway) |
623
+ | region `bounds: 0,0,w,h` + `offsets: 0,0,w,h` | βœ… β€” the 4.x compact form |
624
+ | region `rotate: 0` | βœ… always zero; anything else is 🚫 **A06** ("there is no packer, so nothing can be") |
625
+ | region `index:`, `split:`, `pad:` | ❌ |
626
+ | multi-region pages | ❌ by construction; **A06** requires each region's UVs to be exactly (0,0)-(1,1) |
627
+
628
+ **Binary `.skel`** β€” ❌ not emitted, and out of scope: rigc's whole validation strategy is a JSON
629
+ round-trip through spine-core.
630
+
631
+ ### 2.2 The assertions, classified
632
+
633
+ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for any consumer.
634
+ **Renderer-profile** = the file is valid Spine but violates a spine-html / canvas-budget policy.
635
+ **Archetype** = a structural rule about *our* rigs that no Spine consumer would care about.
636
+
637
+ | Assertion | Kind | What it forbids |
638
+ | --- | --- | --- |
639
+ | `A00_ROUNDTRIP_PARSE` | validity | JSON or atlas that spine-core refuses (cases 6d, 6e) |
640
+ | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | validity | `ik`/`transform`/`path`/`physics` at top level (case 6a) |
641
+ | `A02_NO_BONE_TRANSFORM_KEY` | validity | bone `transform` instead of `inherit` (case 6b) |
642
+ | `A03_REGION_WIDTH_HEIGHT_FINITE` | validity | `NaN`/non-positive region size (case 6c) |
643
+ | `A04_MESH_TRIANGLES_AND_ENCODING` | validity | missing/misaligned `triangles`, out-of-range indices, incoherent vertex run (case 6f) |
644
+ | `A05_CURVE_ARRAY_LENGTH` | validity | short/non-finite bezier arrays, curves on curve-less timelines (case 6g) |
645
+ | `A07_ATLAS_TEXT_SHAPE` | validity | blank line inside a page block, indented region name (the two `TextureAtlas` traps) |
646
+ | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | validity + policy | attachment→region miss is validity; **"v0 requires them identical"** is policy |
647
+ | `A09_ANIMATION_DURATION_MATCHES_SPEC` | spec-fidelity | compiled last-key time β‰  declared duration |
648
+ | `A10_NO_NAN_AFTER_STEPPING` | validity | NaN world transforms / colours after stepping every animation (120 frames) |
649
+ | `A16_SKELETON_VERSION_4_3` | validity (portability) | a `spine` string outside `4.3.x` |
650
+ | `A17_ATLAS_PAGE_FILES_EXIST` | validity | a page PNG not on disk |
651
+ | `A18_DETERMINISTIC_EMIT` | tool contract | recompiling differs byte-for-byte |
652
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | **mixed** | size≠PNG is **validity** (case 6h); `pma:true`, region rotation, and "region covers the whole page" are **renderer-profile** |
653
+ | `A11_NO_CLIPPING_ATTACHMENTS` | **renderer-profile** | clipping attachments β€” "the renderer skips them silently" |
654
+ | `A12_NO_DARK_COLOR` | **renderer-profile** | slot `dark`, `rgba2`/`rgb2` timelines β€” "parsed, then ignored" |
655
+ | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
656
+ | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
657
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page with no alpha channel |
658
+ | `A20_MESH_WEIGHTS_COHERENT` | **renderer-profile / archetype** | **unweighted meshes**, weights not summing to 1, out-of-range bone indices |
659
+ | `A21_MESH_RIM_PINNED` | archetype | ring/ribbon rim not pinned |
660
+ | `A22_MESH_UVS_IN_UNIT_RANGE` | validity-ish | UVs outside 0..1 |
661
+ | `A15_IDLE_NO_MESH_BONE_KEYS` | archetype | `idle` keying a mesh-driving bone |
662
+ | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | validity-ish | a physics constraint that parses and does nothing |
663
+ | `A24_AXIS_SPACE_STROKE` | archetype | screen-space stroke keys |
664
+ | `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares detached, parented under the bone it must stay clear of |
665
+ | `A26_SLOT_DRAW_ORDER` | archetype | draw order β‰  the archetype's slot table |
666
+ | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | policy | region name β‰  PNG basename |
667
+ | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | ribbon rows with divergent weights |
668
+ | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | stroke past the measured contact depth |
669
+ | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | stroke past the measured containment ceiling |
670
+
671
+ **Nine assertions are renderer-profile or profile-mixed** (A06 partly, A11, A12, A13, A14, A19, A20,
672
+ plus A08/A27's "identical names" policy). Every one of them would fire on real Spine example data.
673
+ They are correct for spine-html; they are wrong as *spec* gates, and they must become a
674
+ profile switch (`--profile spine-html` vs `--profile spine`) before rung 1.
675
+
676
+ **Twelve are archetype assertions** (A15, A21, A24–A26, A28–A30, plus the parts of A20/A21 that
677
+ encode ring/ribbon geometry). They are already gated on `input.rig` being present and **SKIP**
678
+ when validating a bare directory (`validate.ts:47-55`, `:808`), which is the right shape β€” nothing
679
+ needs to change there, they simply will not run on example data.
680
+
681
+ ### 2.3 What the round-trip checks β€” and what it lets through
682
+
683
+ **Checked**: the artifact is parsed by the real `TextureAtlas` and `SkeletonJson` (A00), then every
684
+ animation is stepped 120 frames through a real `Skeleton` + `AnimationState` looking for non-finite
685
+ world transforms and colours (A10, `validate.ts:703-733`), then the compiler is re-run and the two
686
+ emits compared byte-for-byte (A18).
687
+
688
+ **Let through silently** β€” the honest list:
689
+
690
+ 1. **Whole timeline groups the walker never visits.** `walkTimelines` (`validate.ts:1159-1199`)
691
+ descends only `animations.<a>.bones`, `.slots`, `.physics`, and `.attachments`. An `ik`,
692
+ `transform`, `path`, `slider`, `drawOrder`, `drawOrderFolder`, or `events` block is **not visited
693
+ at all**, so A05 never sees its curves. Adding those emitters without extending the walker
694
+ silently disables the format's most dangerous check.
695
+ 2. **Unknown top-level and per-map keys.** Nothing enumerates what the parser reads, so a typo'd
696
+ key (`"inherits"`, `"tringles"`) is invisible to both parser and validator.
697
+ 3. **Semantics the parser cannot see.** A wrong-but-parseable `inherit` value, a `blend` mode, a
698
+ slot `visible: false`, a linked mesh pointing at the wrong source β€” none are checked.
699
+ 4. **`skin` / `skinRequired` interactions** β€” rigc emits one skin, so nothing is exercised.
700
+ 5. **A05's own escape hatch is a fail, not a pass** (`validate.ts:275-278`): an unrecognised timeline
701
+ name inside a *visited* group fails with `unchecked <kind> timeline "<name>" β€” extend the
702
+ validator`. That is good design, and it means new bone/slot/physics timelines cannot be added
703
+ without touching the channel tables. It does **not** apply to the groups in item 1.
704
+
705
+ `selftest.ts` runs 35 named mutants (M01–M35) across three fixtures, each mutant asserting that a
706
+ specific assertion fires. The mutant set mirrors the assertion set, so the same blind spots apply:
707
+ there is no mutant for an IK or draw-order timeline because there is no emitter and no walker for
708
+ one.
709
+
710
+ > πŸ”Ό Both halves of that paragraph are now stale. The walker reaches all eleven groups and the
711
+ > mutants that prove it exist; the suite runs on fixtures it **generates**, so it needs no
712
+ > project's art. Current shape: [CLAUDE.md](../CLAUDE.md), *The selftest and its fixtures*.
713
+
714
+ ### 2.4 The input model β€” where the rig actually comes from
715
+
716
+ Two data files in, per `types.ts:1-13`:
717
+
718
+ - **cut manifest** (`FaceManifest`) β€” owns *geometry*: `crop`, per-part `offset`/`size`,
719
+ `polygon`, `state_machine`, `anchors` (bone positions in crop pixels), `axis`, `stroke`.
720
+ - **motion spec** (`MotionSpec`, `spec: 'rigc-motion/1'`) β€” owns *time*: `easings`, `groups`,
721
+ `setup` (per-slot setup pose), `physics` constraint table, `animations{ tracks[] }`, `mix`.
722
+
723
+ **The bone tree is code, not data.** `src/archetype.ts` hard-codes three archetypes:
724
+
725
+ | Archetype | Bone tree | Mesh budget | Slots |
726
+ | --- | --- | --- | --- |
727
+ | 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 |
728
+ | overlay, tier 2 (`:84-88`) | identical | 3 | identical |
729
+ | 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` |
730
+
731
+ The manifest supplies *positions* for those bones (`anchors`), never their *names*, *parentage*, or
732
+ *count*. Consequences, stated plainly:
733
+
734
+ - A slot not in the archetype's `slotBone` table is a **hard compile error**
735
+ (`compile.ts:412-417`: *"extend its slot table rather than inventing one"*).
736
+ - A control bone not in the archetype tree is a hard error (`compile.ts:420-426`).
737
+ - Therefore **no example project can be compiled by rigc today at any rung** β€” not because a feature
738
+ is missing, but because there is no way to *state* a skeleton whose bone tree is not one of these
739
+ three. This is the single largest item in Part 4(b) and it gates everything else.
740
+
741
+
742
+ ---
743
+
744
+ ## Part 3 β€” what the example ladder actually uses
745
+
746
+ Corpus: the nine official example projects on branch `4.3`, fetched by
747
+ [`scripts/fetch-examples.sh`](../scripts/fetch-examples.sh) into the gitignored `examples/<name>/`
748
+ (**12 skeleton JSON files** β€” some examples ship more than one β€” **10 `.atlas` files**, 8 `license.txt`).
749
+ Counted by [`bench/count_features.ts`](../bench/count_features.ts); full output in
750
+ [`feature_matrix.json`](feature_matrix.json) and [`feature_matrix.csv`](feature_matrix.csv)
751
+ (12 rows Γ— 135 columns), both regenerated by `bun run bench:usage`.
752
+
753
+ > ⚠️ **Verification note.** The timeline columns below were re-derived independently from the raw JSON
754
+ > and reconciled against the CSV before publication. Where a prose summary and the machine output
755
+ > disagreed, the machine output won. Everything in these tables is from the CSV.
756
+
757
+ ### 3.0 Corpus-wide facts
758
+
759
+ - **Every one of the 12 files reports `"spine": "4.3.75-beta"`.** No legacy exports in the corpus.
760
+ - **Every file with constraints uses the flat 4.3 `constraints[]` shape**; all four transform-constraint
761
+ users use the 4.3 `source` + `properties` model. **The legacy 4.0–4.2 array shape never appears**, so
762
+ this corpus does *not* exercise case 6a β€” do not treat "the ladder is green" as evidence
763
+ that a legacy-shape importer works.
764
+ - **Every atlas uses the 4.x compact `bounds:` / `offsets:` region form.** The deprecated
765
+ `xy:`/`size:`/`orig:`/`offset:` region form never appears.
766
+ - **Zero unknown keys anywhere** β€” every top-level, bone, slot, and attachment key in all 12 files is
767
+ one the 4.3 parser reads. The corpus is a clean specimen of exactly the surface Part 1 enumerates.
768
+ - **No file uses:** named skins (every file has one skin, `default`), linked meshes, path constraints,
769
+ slider constraints, sequences, slot `dark` colour, `drawOrderFolder`, bone `visible`, or path/point
770
+ attachments.
771
+ - **Nine of ten atlases carry a page `scale:` line** that spine-ts ignores (Β§1.10).
772
+
773
+ ### 3.1 Structure and attachments
774
+
775
+ | File | bones | slots | skins | region | mesh | bbox | clip | mesh W/U | mesh verts | mesh tris | max verts in one |
776
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
777
+ | `1-weight-and-mass-balls-ess` | 9 | 8 | 1 | 8 | 0 | 0 | 0 | – | – | – | – |
778
+ | `1-weight-and-mass-drop-ess` | 4 | 5 | 1 | 5 | 0 | 0 | 0 | – | – | – | – |
779
+ | `2-the-12-principles-ess` | 12 | 17 | 1 | 17 | 0 | 0 | 0 | – | – | – | – |
780
+ | `3-timing-and-spacing-ess` | 3 | 2 | 1 | 2 | 0 | 0 | 0 | – | – | – | – |
781
+ | `4-wave-principle-ess` | 9 | 9 | 1 | 9 | 0 | 0 | 0 | – | – | – | – |
782
+ | `5-squash-and-stretch-ess` | 16 | 13 | 1 | 29 | 0 | 0 | 0 | – | – | – | – |
783
+ | `6-arcs-pro` | 14 | 4 | 1 | 2 | 2 | 0 | 0 | **2 / 0** | 44 | 40 | 40 |
784
+ | `7-anticipation / sack-pro` | 31 | 3 | 1 | 0 | 3 | 0 | 0 | **3 / 0** | 193 | 230 | 96 |
785
+ | `8-follow-through-pro-ball` | 12 | 2 | 1 | 0 | 2 | 0 | 0 | **2 / 0** | 34 | 30 | 30 |
786
+ | `8-follow-through-pro-pendulum` | 7 | 6 | 1 | 6 | 0 | 0 | 0 | – | – | – | – |
787
+ | `spineboy-ess` | 18 | 20 | 1 | 26 | 0 | **1** | 0 | – | – | – | – |
788
+ | `spineboy-pro` | **67** | **52** | 1 | 66 | **12** | **1** | **1** | **10 / 2** | 322 | 430 | 74 |
789
+
790
+ All meshes in the corpus carry both `edges` and `hull`.
791
+
792
+ ### 3.2 Timelines actually keyed (verified twice)
793
+
794
+ | File | bone timelines | slot timelines | constraint / other |
795
+ | --- | --- | --- | --- |
796
+ | `1-w&m-balls-ess` | translate, translatex, translatey, scale, shear | **rgba** | β€” |
797
+ | `1-w&m-drop-ess` | *(none β€” a static skeleton)* | β€” | β€” |
798
+ | `2-the-12-principles-ess` | rotate, translate, translatex, translatey, scale, shear | attachment, **rgba** | β€” |
799
+ | `3-timing-and-spacing-ess` | rotate, translatex, translatey | β€” | β€” |
800
+ | `4-wave-principle-ess` | rotate, translatex, translatey, scale | attachment | β€” |
801
+ | `5-squash-and-stretch-ess` | rotate, translate, translatex, translatey, scale | attachment, **rgba** | **drawOrder** (2 keys) |
802
+ | `6-arcs-pro` | rotate, translate, translatex, translatey, scale | attachment | *(4 transform constraints, but **no** transform timeline β€” they are static)* |
803
+ | `7-anticipation / sack-pro` | rotate, translate, translatex, translatey, scale | β€” | **transform** (11 keys), **physics**: inertia, strength, damping, mass, wind, mix, **deform** (1 key) |
804
+ | `8-follow-through-pro-ball` | rotate, translate, translatex, translatey | β€” | *(4 transform constraints, no transform timeline)* |
805
+ | `8-follow-through-pro-pendulum` | rotate, translatex, translatey | β€” | β€” |
806
+ | `spineboy-ess` | rotate, translate, scale | attachment, **rgba** | **drawOrder** (1 key), **events** (5 keys) |
807
+ | `spineboy-pro` | rotate, translate, scale, shear | attachment, **rgba** | **ik** (29 keys), **transform** (6 keys), **deform** (9 keys), **events** (5 keys) |
808
+
809
+ Notable absences across the whole corpus: `inherit`, `scalex`/`scaley`, `shearx`/`sheary`,
810
+ `rgb`, `alpha`, `rgba2`, `rgb2`, `sequence`, `drawOrderFolder`, `path.*`, `slider.*`,
811
+ `physics.gravity`, `physics.reset`.
812
+
813
+ ### 3.3 Constraints, curves, events, blend, inheritance
814
+
815
+ | File | ik | transform | path | physics | slider | curve linear / stepped / bezier | bezier array lengths | slot blend β‰  normal | bone inherit β‰  Normal | events defined |
816
+ | --- | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: |
817
+ | `1-w&m-balls-ess` | 0 | 0 | 0 | 0 | 0 | 206 / 1 / 44 | 4 | 0 | 0 | 0 |
818
+ | `1-w&m-drop-ess` | 0 | 0 | 0 | 0 | 0 | 0 / 0 / 0 | β€” | 0 | 0 | 0 |
819
+ | `2-the-12-principles-ess` | 0 | 0 | 0 | 0 | 0 | 150 / 127 / **1010** | 4, 8 | **8** (4 additive, 4 multiply) | 4 Γ— `noRotationOrReflection` | 0 |
820
+ | `3-timing-and-spacing-ess` | 0 | 0 | 0 | 0 | 0 | 2 / 1 / 58 | 4 | 0 | 0 | 0 |
821
+ | `4-wave-principle-ess` | 0 | 0 | 0 | 0 | 0 | 36 / 8 / 470 | 4 | **2** (1 additive, 1 multiply) | 1 Γ— `noRotationOrReflection` | 0 |
822
+ | `5-squash-and-stretch-ess` | 0 | 0 | 0 | 0 | 0 | 162 / 82 / **1665** | 4, 8 | 0 | **8 Γ— `onlyTranslation`** | 0 |
823
+ | `6-arcs-pro` | 0 | **4** | 0 | 0 | 0 | 18 / 7 / 498 | 4, 8 | 0 | 0 | 0 |
824
+ | `7-anticipation / sack-pro` | 0 | **4** | 0 | **20** | 0 | 171 / 62 / 757 | 4, 8 | 0 | 3 Γ— `noScale` | 0 |
825
+ | `8-follow-through-pro-ball` | 0 | **4** | 0 | 0 | 0 | 22 / 5 / 328 | 4, 8 | 0 | 0 | 0 |
826
+ | `8-follow-through-pro-pendulum` | 0 | 0 | 0 | 0 | 0 | 1 / 0 / 156 | 4 | 0 | 0 | 0 |
827
+ | `spineboy-ess` | 0 | 0 | 0 | 0 | 0 | 43 / 8 / 526 | 4, 8 | 1 additive | 0 | **1** (5 keys) |
828
+ | `spineboy-pro` | **7** (2 multi-bone) | **7** | 0 | 0 | 0 | 90 / 88 / **1136** | 4, 8, **16** | **25 additive** | 4 Γ— `noRotationOrReflection` | **1** (5 keys) |
829
+
830
+ Bezier array length is `4 Γ— channels` (Β§1.9). Observed lengths **4** (1-channel: rotate, translatex/y),
831
+ **8** (2-channel: translate, scale) and **16** (4-channel: rgba, spineboy-pro only). Never 12/24 β€”
832
+ so no example ever puts a bezier on a `transform`, `path mix`, or `rgb` timeline.
833
+
834
+ Nonessential data is present throughout: `hash` and `images` in all 12; bone `color` in 9 files
835
+ (up to 42 bones in spineboy-pro); bone `icon` in 8 files.
836
+
837
+ ### 3.4 Atlases
838
+
839
+ | Atlas | pages | regions | region form | `scale:` | `pma:` | rotated regions | index / split / pad |
840
+ | --- | ---: | ---: | --- | --- | --- | --- | --- |
841
+ | `1-weight-and-mass.atlas` | 1 | 13 | compact | `0.5` | absent | **1** (`rotate: 90`, `ground-bg`) | β€” |
842
+ | `2-the-12-principles.atlas` | 1 | 15 | compact | `0.4` | absent | 0 | β€” |
843
+ | `3-timing-and-spacing.atlas` | 1 | 2 | compact | `0.5` | absent | 0 | β€” |
844
+ | `4-wave-principle.atlas` | 1 | 8 | compact | `0.5` | absent | 0 | β€” |
845
+ | `5-squash-and-stretch.atlas` | 1 | 29 | compact | `0.5` | absent | **1** (`rotate: 90`) | β€” |
846
+ | `6-arcs.atlas` | 1 | 4 | compact | `0.5` | absent | 0 | β€” |
847
+ | `7-anticipation.atlas` | 1 | 3 | compact | `0.5` | absent | **1** (`rotate: 270`, `cape-back`) | β€” |
848
+ | `8-follow-through.atlas` | 1 | 8 | compact | `0.5` | absent | 0 | β€” |
849
+ | `spineboy.atlas` | 1 | 50 | compact | `0.5` | absent | 0 | β€” |
850
+ | `spineboy-run.atlas` | 1 | 10 | compact | absent | **`true`** | 0 | **`index: 8`** on all 10 |
851
+
852
+ Two things worth flagging:
853
+
854
+ - **`rotate: 270` exists in the wild** (`7-anticipation`), so a reader that treats `rotate` as a
855
+ boolean is wrong on real data β€” matching `TextureAtlas.ts:91-97`'s three-way branch.
856
+ - **`spineboy-run.atlas` is a degenerate file**: ten region entries, all named `spineboy-pro-run`,
857
+ all with `index: 8` and identical `bounds`. Byte length matches upstream (869 B), so this is what
858
+ the repository ships, not a fetch artefact. It is not referenced by either spineboy skeleton
859
+ (both use `spineboy.atlas`) and it is the **only** `pma: true` page in the corpus. Treat it as an
860
+ outlier, not as a ladder target.
861
+
862
+ ### 3.5 Licences β€” **not uniform, and one example has none**
863
+
864
+ Eight of nine examples ship `license.txt`; **`7-anticipation` has none** (verified: 404 for both
865
+ `license.txt` and `LICENSE.txt`, and no licence file in that example's directory listing). The eight
866
+ present files fall into **five byte-distinct variants that differ only in the copyright line**:
867
+
868
+ | Variant | Examples | Copyright line |
869
+ | --- | --- | --- |
870
+ | A | `1-weight-and-mass`, `2-the-12-principles`, `4-wave-principle` | `Copyright (c) 2021-2025, Esoteric Software LLC` |
871
+ | B | `3-timing-and-spacing`, `5-squash-and-stretch` | `Copyright (c) 2021-2025, Esoteric Software` |
872
+ | C | `6-arcs` | `Copyright (c) 2022-2025, Esoteric Software` |
873
+ | D | `8-follow-through` | `Copyright (c) 2024-2025, Esoteric Software` |
874
+ | E | `spineboy` | `Copyright (c) 2013, Esoteric Software LLC` |
875
+
876
+ The body is identical in all five:
877
+
878
+ > The images in this project may be redistributed as long as they are accompanied by this license
879
+ > file. The images may not be used for commercial use of any kind.
880
+ >
881
+ > The project file is released into the public domain. It may be used as the basis for derivative work.
882
+
883
+ **What this means for the ladder**, stated as a chain and not as legal advice:
884
+
885
+ 1. **Images** β€” redistributable *only* alongside the licence file, and **not for commercial use**.
886
+ A benchmark that ships the example PNGs is fine as a non-commercial research artefact; shipping
887
+ them inside a commercial product is not.
888
+ 2. **Project files / exports** β€” public domain. So the `.json`/`.atlas` we are trying to *reproduce*
889
+ carry no restriction, and a rigc-generated look-alike inherits nothing.
890
+ 3. **`7-anticipation` has no grant on file at all.** Its images and `sack-pro.json` are governed only
891
+ by the repository-level Spine Runtimes License. It is the one rung where "we redistributed the
892
+ images" is not covered by an explicit permission β€” **use it for measurement, do not vendor its
893
+ images**. Since it is also the physics rung, this is worth deciding before rung 7.
894
+ 4. Independent of any of this, rigc links spine-core, so its own `NOTICE.md` chain (each user needs a
895
+ Spine Editor licence) applies regardless of which example is being reproduced.
896
+
897
+
898
+ ---
899
+
900
+ ## Part 4 β€” the gap list, ordered by the ladder
901
+
902
+ Crossing Part 2 with Part 3. Categories, as requested:
903
+ **(a) emitter** β€” new attachment / constraint / timeline / atlas kinds Β·
904
+ **(b) input model** β€” what the spec files must be able to *say* Β·
905
+ **(c) validator** β€” assertions that must become a profile switch or be widened Β·
906
+ **(d) JSON-inexpressible** β€” things the format genuinely cannot hold.
907
+
908
+ **Nothing below was implemented when this was written. This is a survey.** Three of its items
909
+ have been built since β€” see the banner at the top of this document and [LADDER.md](LADDER.md) for
910
+ which; the requirement text itself is left exactly as surveyed.
911
+
912
+ ### 4.0 Three blockers that precede rung 1
913
+
914
+ These are not per-rung; they gate the whole ladder and should be settled before rung 1 is attempted.
915
+
916
+ | # | Blocker | Category | Detail |
917
+ | --- | --- | --- | --- |
918
+ | **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. |
919
+ | **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. |
920
+ | **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. |
921
+
922
+ ### 4.1 Cumulative per-rung requirements
923
+
924
+ Each row lists only what is **new at that rung**. Read cumulatively: to stand on rung N you need
925
+ rows 1..N plus Β§4.0.
926
+
927
+ #### Rung 1 β€” `1-weight-and-mass` (2 skeletons: `balls-ess`, `drop-ess`)
928
+
929
+ | Cat | Requirement |
930
+ | --- | --- |
931
+ | (a) | Bone timelines **`translatex`**, **`translatey`**, **`shear`** β€” rigc emits only `translate`/`scale`/`rotate` |
932
+ | (a) | Bone setup **`length`** (3 bones in `drop-ess`) β€” cosmetic in a renderer, but part of a faithful reproduction |
933
+ | (a) | Region attachment **`rotation`** on 3 attachments *(rigc emits `rotation` already, as a bone-cancel term β€” it would need to become an authorable value)* |
934
+ | (a) | Atlas: **multi-region packed page**, per-region `bounds`/`offsets`, **`rotate: 90`**, page `scale:` line |
935
+ | (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 |
936
+ | (c) | **A06** β€” "region covers the whole page" and "no rotated region" must move behind the profile switch |
937
+ | (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) |
938
+ | (d) | none |
939
+
940
+ #### Rung 2 β€” `2-the-12-principles`
941
+
942
+ | Cat | Requirement |
943
+ | --- | --- |
944
+ | (a) | **Slot `blend`** β€” first appearance: 4 additive + 4 multiply slots |
945
+ | (a) | **Bone `inherit`** β€” first appearance: 4 Γ— `noRotationOrReflection` |
946
+ | (b) | The manifest must be able to declare per-slot blend and per-bone inherit |
947
+ | (c) | A05 already knows the channel counts for every bone timeline; no change needed for this rung's curves (4- and 8-length beziers) |
948
+ | (d) | none |
949
+
950
+ #### Rung 3 β€” `3-timing-and-spacing`
951
+
952
+ Nothing new. **This is the smallest skeleton in the corpus (3 bones, 2 slots, 2 animations) and is
953
+ the natural *first* rung** despite its number β€” it needs only B1–B3 plus `translatex`/`translatey`.
954
+
955
+ #### Rung 4 β€” `4-wave-principle`
956
+
957
+ Nothing structurally new (attachment timeline, blend, inherit all arrived by rung 2). It is a
958
+ **volume** test: 9 bones, 9 slots, 3 animations, 470 bezier keys.
959
+
960
+ #### Rung 5 β€” `5-squash-and-stretch`
961
+
962
+ | Cat | Requirement |
963
+ | --- | --- |
964
+ | (a) | **`drawOrder` timeline** β€” πŸ”΄ first appearance (2 keys). rigc emits none, and the *validator does not walk the group at all* (`validate.ts:1166-1170`) |
965
+ | (a) | **Bone `inherit: onlyTranslation`** (8 bones); non-unit setup `scaleX`/`scaleY` (3 bones) |
966
+ | (b) | A draw-order track type in the motion spec (a list of slot offsets over time) |
967
+ | (c) | Extend `walkTimelines` to visit `drawOrder`, and give A05 a "no curve here" entry for it |
968
+ | (d) | none |
969
+
970
+ #### Rung 6 β€” `6-arcs`
971
+
972
+ > βœ… **Measured, 2026-08-23.** The rows below were written as predictions; the
973
+ > transcription in [`bench/transcriptions/6-arcs/`](../bench/transcriptions/6-arcs/)
974
+ > settled them. It compiles green under `--profile spine` and `bench 6` reports
975
+ > **1.000 on all 44 measures in all six sections**, so the rig spec expresses this
976
+ > skeleton β€” weighted meshes, `edges` and the 4.3 transform constraints alike. A
977
+ > field-by-field comparison against the reference export leaves 49 differences and
978
+ > every one is benign: 39 are defaults rigc writes explicitly where the editor
979
+ > omits them, 3 are editor bookkeeping (`hash`, `images`, `audio`), 1 is the runtime
980
+ > version string, and **6 are bone `icon`** β€” an editor decoration the rig spec had
981
+ > no field for and the only thing here it could not say. `RigBone.icon` carries it
982
+ > since issue #47; the transcription itself has not been re-cut. What did not
983
+ > survive contact is noted per row.
984
+
985
+ | Cat | Requirement |
986
+ | --- | --- |
987
+ | (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 |
988
+ | (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 |
989
+ | (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) |
990
+ | (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 `R08`). Spine's index run survives behind an explicit `"boneIndexing": "raw"`, whose cost β€” silence β€” `MR07` still measures |
991
+ | (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 |
992
+ | (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 |
993
+ | (c) | A13's mesh budget (≀4 slots, ≀80 tris) is **satisfied** at this rung (2 slots, max 38 tris) |
994
+ | (d) | none |
995
+
996
+ #### Rung 7 β€” `7-anticipation` (`sack-pro`)
997
+
998
+ | Cat | Requirement |
999
+ | --- | --- |
1000
+ | (a) | **Physics timelines** `inertia`, `strength`, `damping`, `mass`, `wind` β€” rigc emits only `mix` and `reset` |
1001
+ | (a) | **`transform` constraint timeline** β€” πŸ”΄ first appearance as a *keyed* timeline (11 keys); rung 6's transform constraints were static |
1002
+ | (a) | **`deform` timeline** β€” πŸ”΄ first appearance (1 key). Also needs the `animations.<a>.attachments.<skin>.<slot>.<att>` container, which rigc never writes |
1003
+ | (a) | 20 physics constraints on a 31-bone tree; bone `inherit: noScale` |
1004
+ | (b) | Per-vertex deform keys in the motion spec |
1005
+ | (c) | 🚨 **A13_MESH_BUDGET fails**: the `sack` mesh has **116 triangles** against a budget of 80. Renderer-profile β€” must become a switch |
1006
+ | (c) | A24/A29/A30 (axis-space stroke, contact depth, containment ceiling) are archetype assertions and will correctly **SKIP** β€” no change needed |
1007
+ | (βš–οΈ) | **Licence**: this is the one example with **no `license.txt`** (Β§3.5). Measure against it; do not vendor its images |
1008
+ | (d) | none |
1009
+
1010
+ #### Rung 8 β€” `8-follow-through` (2 skeletons: `pro-ball`, `pro-pendulum`)
1011
+
1012
+ Nothing new β€” transform constraints (static, 4) and weighted meshes (2, max 28 tris) both arrived at
1013
+ rung 6. `pro-pendulum` is region-only and is effectively a rung-3-class file.
1014
+
1015
+ #### Top rung β€” `spineboy` (2 skeletons: `ess`, `pro`)
1016
+
1017
+ | Cat | Requirement |
1018
+ | --- | --- |
1019
+ | (a) | **IK constraints** β€” πŸ”΄ first appearance: 7, of which 2 are multi-bone (two-bone IK) |
1020
+ | (a) | **IK timelines** β€” 29 keys, 2-channel curves (mix, softness) |
1021
+ | (a) | **Events** β€” πŸ”΄ first appearance: the skeleton-level `events` block *and* the `events` timeline (5 keys) |
1022
+ | (a) | **Bounding-box attachments** β€” πŸ”΄ first appearance (1, in both `ess` and `pro`) |
1023
+ | (a) | **Clipping attachment** β€” πŸ”΄ first appearance (1, `pro` only) |
1024
+ | (a) | **Unweighted meshes** β€” πŸ”΄ first appearance (2 of 12 in `pro`) |
1025
+ | (a) | Multi-page atlas handling; 4-channel (`rgba`, length-16) bezier curves β€” rigc already emits these correctly |
1026
+ | (a) | Scale: 67 bones, 52 slots, 11 animations, 80 attachments |
1027
+ | (b) | Event definitions and event tracks in the motion spec; bounding-box and clipping attachment declarations |
1028
+ | (c) | 🚫 **A11_NO_CLIPPING_ATTACHMENTS** fails β€” renderer-profile |
1029
+ | (c) | 🚫 **A20_MESH_WEIGHTS_COHERENT** fails on the 2 unweighted meshes β€” renderer-profile |
1030
+ | (c) | 🚨 **A13_MESH_BUDGET** fails twice over: 9 mesh slots (budget 4) and two meshes at 95 / 101 triangles (budget 80) |
1031
+ | (c) | Extend `walkTimelines` to visit `ik`, `transform`, and `events` β€” otherwise A05 silently stops guarding this rung's curves |
1032
+ | (d) | none |
1033
+
1034
+ ### 4.2 First appearance of each named feature
1035
+
1036
+ | Feature | First rung | Where |
1037
+ | --- | --- | --- |
1038
+ | Packed / rotated atlas | **1** | `1-weight-and-mass` (`rotate: 90`) |
1039
+ | Slot blend modes | **2** | `2-the-12-principles` (additive + multiply) |
1040
+ | Bone `inherit` β‰  Normal | **2** | `2-the-12-principles` (`noRotationOrReflection`) |
1041
+ | **drawOrder** | **5** | `5-squash-and-stretch` (2 keys) |
1042
+ | **Transform constraints** | **6** | `6-arcs` (4, static) |
1043
+ | **Weighted meshes** | **6** | `6-arcs` (2 meshes, 44 verts) |
1044
+ | **Physics** | **7** | `7-anticipation` (20 constraints + 6 timeline kinds) |
1045
+ | **Deform** | **7** | `7-anticipation` (1 key) |
1046
+ | Keyed transform timeline | **7** | `7-anticipation` (11 keys) |
1047
+ | **IK** | **spineboy** | `spineboy-pro` (7, incl. 2 multi-bone) |
1048
+ | **Clipping** | **spineboy** | `spineboy-pro` (1) |
1049
+ | Bounding box | **spineboy** | `spineboy-ess` and `-pro` (1 each) |
1050
+ | Events | **spineboy** | both spineboy files (1 def, 5 keys) |
1051
+ | Unweighted meshes | **spineboy** | `spineboy-pro` (2) |
1052
+ | **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 should be scheduled by some other justification. |
1053
+ | Path constraints, sliders, sequences, linked meshes, dark colour, `drawOrderFolder` | **never** | absent from the entire corpus |
1054
+
1055
+ ### 4.3 (c) The validator changes, consolidated
1056
+
1057
+ The single structural change: **split the assertion set into profiles.** Concretely β€”
1058
+
1059
+ 1. **`--profile spine`** (spec-only): A00–A05, A07, A09, A10, A16 (widened to accept
1060
+ `4.3.75-beta`-style suffixes), A17, A18, A22, A23. These stay on for every rung.
1061
+ 2. **`--profile spine-html`** (today's behaviour = spec + renderer policy): adds A06's PMA/rotation/
1062
+ full-page clauses, A08/A27's name-identity policy, A11, A12, A13, A14, A19, A20.
1063
+ 3. **Archetype assertions** (A15, A21, A24–A26, A28–A30) already SKIP without `RigInfo`
1064
+ (`validate.ts:47-55`, `:808`) β€” no change; they simply never run on example data.
1065
+ 4. **Widen `walkTimelines`** (`validate.ts:1159-1199`) to `ik`, `transform`, `path`, `slider`,
1066
+ `drawOrder`, `drawOrderFolder`, `events`, with channel tables for each (Β§1.8 has the numbers).
1067
+ Do this **before** the corresponding emitters land, not after β€” an unwalked group is a check that
1068
+ silently reports green.
1069
+ 5. Keep A05's "unchecked timeline β€” extend the validator" fail-closed behaviour. It is the reason
1070
+ this document could enumerate the gap at all.
1071
+
1072
+ ### 4.4 (d) What JSON cannot express
1073
+
1074
+ Only two things, both editor-affordance data with **zero rendering effect** (Β§1.11):
1075
+
1076
+ - **`bone.visible`** β€” read by `SkeletonBinary.ts:126`, not by `SkeletonJson`.
1077
+ - **`skin.color`** β€” read by `SkeletonBinary.ts:448`, not by `SkeletonJson`.
1078
+
1079
+ Neither appears in the ladder corpus (Β§3.0) and neither affects a rendered frame. **There is no
1080
+ rendering-relevant feature that the binary format can express and JSON cannot**, so "we only emit
1081
+ JSON" costs the benchmark nothing.
1082
+
1083
+ One near-miss worth naming: the atlas page **`scale:`** line is emitted by the Spine packer and read
1084
+ by *nobody* in spine-ts (Β§1.10). Reproducing it byte-for-byte is possible; reproducing its *effect*
1085
+ is not, because there is no effect to reproduce in this runtime.
1086
+
1087
+ ---
1088
+
1089
+ ## Things I could not verify
1090
+
1091
+ 1. **PNG-level facts.** `count_features.ts` reads only `.json` and `.atlas`, so
1092
+ `A19_OVERLAY_PNGS_HAVE_ALPHA` and `A06`'s "declared `size:` matches the file" were not evaluated
1093
+ against the corpus. `fetch-examples` does download the pages, and `src/png.ts` already measures
1094
+ PNG headers, so this is cheap to add.
1095
+ 2. **`A14_NO_FULL_FRAME_MESH`** β€” needs `skeleton.width/height` compared against each mesh's
1096
+ `width`/`height`; not computed per file.
1097
+ 3. **Binary `.skel` behaviour** was read from source only; I did not parse an actual `.skel`.
1098
+ 4. **`spineboy-run.atlas`'s ten identical regions** β€” the file matches upstream byte length, so it is
1099
+ genuinely what the repo ships, but *why* it is shaped that way (a broken sequence export?) is
1100
+ unexplained. It is unreferenced by both spineboy skeletons.
1101
+ 5. **Whether the Spine editor itself would round-trip a rigc file.** Esoteric documents JSON import
1102
+ (Β§"On third-party tools"), but nothing here tested it; the round-trip proof in this project is
1103
+ spine-core-only.
1104
+ 6. **Collection ran against a rate-limited GitHub REST API**, so some directory listings came from an
1105
+ HTML fallback rather than the contents API. File contents came from `raw.githubusercontent.com`
1106
+ and byte lengths were spot-checked against upstream `Content-Length`. `fetch-examples.sh` uses the
1107
+ contents API and honours `GITHUB_TOKEN`; export one if a re-run hits the 60/hour limit.
1108
+ 7. **Numbers here are a snapshot of branch `4.3`.** Re-run `bun run bench:usage` after a
1109
+ `fetch-examples` to confirm the corpus has not moved before relying on a count.