rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/ingest.ts ADDED
@@ -0,0 +1,2293 @@
1
+ /**
2
+ * ingest — Spine 4.3 skeleton JSON back into a rig spec and a motion spec.
3
+ *
4
+ * ## What this is, and what makes it checkable
5
+ *
6
+ * `build` turns two spec files into a skeleton. This turns a skeleton back into
7
+ * two spec files, so that the contract is an **equality against the file it was
8
+ * read from**: `build(ingest(A)) === A`, byte for byte on `skeleton.json`. Every
9
+ * other gate in this repository compares rigc to rigc — the compiler against the
10
+ * validator, one compile against a second (`A18`), the emitter against its own
11
+ * assertions. This one compares rigc's output against an input rigc did not
12
+ * write, which is the only reference of that kind the tree has.
13
+ *
14
+ * ⇒ So every function below is an **inversion of one named function in
15
+ * [`compile.ts`](compile.ts)**, and each says which. That citation is what makes
16
+ * the module reviewable: a reader checks the pair, not the prose.
17
+ *
18
+ * ## The rule it is held to
19
+ *
20
+ * 🔒 **A decompiler never invents a value the skeleton does not carry.** It is
21
+ * CLAUDE.md's *"the compiler never invents a value that is not in the spec"*,
22
+ * mirrored — and the mirror is where a decompiler's defects live, because a
23
+ * plausible guess here produces a spec that compiles, gates green and says
24
+ * something nobody wrote. Where the skeleton cannot answer, this records a
25
+ * **finding** with a code and writes nothing: `findings` is the product, not a
26
+ * log. Exactly two values are not in a skeleton at all (the stage and an
27
+ * animation's duration), and a value somebody supplies for either is a
28
+ * `judgement` finding — a stage the file does not declare is carried as the
29
+ * absence it is, and only a caller's `--stage` is somebody deciding (issue
30
+ * #714); every construct the spec format cannot hold is a `blocker`; everything
31
+ * rigc re-derives rather than carries is `lossy`. The one thing it refuses outright rather than recording is
32
+ * an OPTION that contradicts the file — see `IngestError`.
33
+ *
34
+ * ## What it does not read
35
+ *
36
+ * Skeleton JSON, and nothing else. Not the atlas, not a `.spine` project, not a
37
+ * binary `.skel`, not the art. A rig spec's texture side is therefore the
38
+ * caller's (`IngestOptions.art`) and is stated as such.
39
+ *
40
+ * ## Purity
41
+ *
42
+ * No clock, no randomness, no filesystem, no network, and no `spine-core` — the
43
+ * three files allowed to link the runtime are named in CLAUDE.md and this is not
44
+ * one of them (`CUR07` refuses a fourth). The provenance `note` therefore carries
45
+ * a version the caller passes in and **no timestamp**, because a timestamp would
46
+ * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
47
+ * spec.
48
+ */
49
+ import {
50
+ PHYSICS_COMPONENTS,
51
+ SLOT_TRACKS as EMITTED_SLOT_TRACKS,
52
+ SPINE_VERSION,
53
+ } from './compile.ts';
54
+ import { CompileError } from './errors.ts';
55
+ import { physicsDrives } from './core/constraints_physics.ts';
56
+ import { CHANNELS_BY_KIND } from './timelines.ts';
57
+ import {
58
+ LEGACY_BONE_INHERIT_KEY,
59
+ PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED,
60
+ SPINE_GENERATIONS,
61
+ spineGeneration,
62
+ TOPLEVEL_CONSTRAINT_ARRAYS,
63
+ } from './generation.ts';
64
+ import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
65
+ import { PARSER_DEFAULTS, parserOmits } from './keyorder.ts';
66
+ import { constraintAt, parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
67
+ import type { MotionSpec } from './types.ts';
68
+
69
+ /**
70
+ * The invocation this module refuses outright, rather than recording.
71
+ *
72
+ * ⚠️ **A finding is about the FILE; this is about the call.** Everything below
73
+ * that a skeleton cannot answer is a `finding` and the specs are still written,
74
+ * because a spec plus a list of what is missing from it beats no spec. An
75
+ * `IngestError` is the other thing: an option that contradicts the file it was
76
+ * given, where writing anything at all would be writing something the caller did
77
+ * not ask for. It is one re-run away from everything, which is what makes
78
+ * refusing cheaper than recording here (issue #626).
79
+ */
80
+ export class IngestError extends Error {
81
+ constructor(message: string) {
82
+ super(message);
83
+ this.name = 'IngestError';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The specs this module wrote, and the tree's own parser refusing one of them.
89
+ *
90
+ * 🚨 **A parser refusal mid-`ingest` used to be the run's last word** (issue
91
+ * #692). `parseRigSpec` throws a `CompileError`, it went straight out through
92
+ * `ingest()`, and the run exited 1 with **no `BLOCK` line, no code and no
93
+ * `findings.json` on disk** — so a census that counts finding codes read those
94
+ * files as refused for no stated reason. A shape the spec format cannot hold is
95
+ * exactly what a coded `blocker` is for, and the code rides in `findings` here
96
+ * with everything else the run found.
97
+ *
98
+ * It carries the two spec objects because they are what the parser was given:
99
+ * writing them is what lets the sentence be read against a file rather than
100
+ * against the console. They are the same objects a successful run returns —
101
+ * `parseRigSpec` hands its input back typed — so the bytes on disk do not depend
102
+ * on which way the parse went.
103
+ */
104
+ export class IngestSpecRefused extends Error {
105
+ constructor(
106
+ message: string,
107
+ readonly findings: IngestFinding[],
108
+ readonly rig: JsonObject,
109
+ readonly motion: JsonObject,
110
+ ) {
111
+ super(message);
112
+ this.name = 'IngestSpecRefused';
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // findings
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * What a finding is about, which is also what the caller's exit code turns on.
122
+ *
123
+ * - `blocker` — the spec format cannot say this, so the rebuilt skeleton will
124
+ * NOT be the one that was read. Non-zero exit.
125
+ * - `judgement` — the skeleton does not carry it and somebody decided. There
126
+ * are exactly two: a stage the caller supplied, and an animation's duration.
127
+ * - `lossy` — the skeleton's spelling and rigc's differ, on purpose, and the
128
+ * difference is named: a value rigc re-derives rather than takes (the
129
+ * `spine` version), a field the spec has no home for (`hash`), or
130
+ * a default the source left to the format and the rebuild writes out
131
+ * (`HEADER_ORIGIN`, issue #622). The rebuilt file is a different file in that
132
+ * field; it is not a different rig.
133
+ */
134
+ export type IngestFindingKind = 'blocker' | 'judgement' | 'lossy';
135
+
136
+ /**
137
+ * The gutter each kind prints under — the token a reader meets before the code,
138
+ * and the column `docs/INGEST.md` §2.0's finding-code table is keyed on.
139
+ *
140
+ * ⭐ Exported for the reason `SLOT_TRACKS` below is read off `compile.ts`
141
+ * (issue #650): it was a local table inside `cmdIngest`, so the CLI that prints
142
+ * a gutter, the page that documents one and the gate that compares the two
143
+ * would have held three copies of the same three pairs. The CLI pads it to one
144
+ * width for the column; the width is a printing decision and stays there.
145
+ */
146
+ export const INGEST_GUTTERS: Record<IngestFindingKind, string> = {
147
+ blocker: 'BLOCK',
148
+ judgement: 'JUDGE',
149
+ lossy: 'LOSS',
150
+ };
151
+
152
+ export interface IngestFinding {
153
+ /** Stable code, so a table can count them and a doc can name one. */
154
+ code: string;
155
+ /** The object this is about, named the way a validator failure names one. */
156
+ where: string;
157
+ /** One sentence: what was found, and what it means for the rebuild. */
158
+ detail: string;
159
+ kind: IngestFindingKind;
160
+ }
161
+
162
+ /** The stage a skeleton does not carry. See `NO_STAGE` below. */
163
+ export interface IngestStage {
164
+ x: number;
165
+ y: number;
166
+ width: number;
167
+ height: number;
168
+ }
169
+
170
+ export interface IngestOptions {
171
+ /** The rig spec's `name`, which the motion spec's `archetype` must equal. */
172
+ name: string;
173
+ /**
174
+ * How the rebuilt spec gets at the art.
175
+ *
176
+ * `loose` names an `image` per attachment, so `build --images <dir>` measures
177
+ * the PNGs; `none` states `width`/`height` only, for a rebuild that resolves
178
+ * through `build --atlas-in <pack>`. The skeleton encodes neither, which is
179
+ * why this is a flag rather than a derivation.
180
+ */
181
+ art: 'loose' | 'none';
182
+ /**
183
+ * The rig spec's own `images` — the directory every `image` written below
184
+ * resolves against — ALREADY spelled relative to the directory the spec will
185
+ * be written into (issue #595).
186
+ *
187
+ * Absent leaves the field out, and an absent `images` resolves against the
188
+ * spec's own directory: a caller who extracted the parts anywhere else then
189
+ * carries `build --images <dir>` on every rebuild forever, and a spec that
190
+ * needs a flag to build is a spec whose `note` would have to say so.
191
+ *
192
+ * ⚠️ Spelled by the CALLER, not here. Turning a directory somebody typed into
193
+ * an absolute path reads a working directory, and this module reads nothing;
194
+ * `cli.ts` resolves it and spells it with `relativeImagesPath`, which is the
195
+ * same function `build` spells `skeleton.images` with, so the two cannot
196
+ * drift. Meaningless under `art: 'none'`, which writes no `image` at all —
197
+ * `cli.ts` refuses that pair rather than writing a field nothing reads.
198
+ */
199
+ images?: string;
200
+ /**
201
+ * Supplied stage, for a skeleton that declares none.
202
+ *
203
+ * ⛔ **Beside a skeleton that declares one this is an `IngestError`, not an
204
+ * override** (issue #626). It used to be read only after the early return in
205
+ * `ingestHeader`, so a caller who passed it alongside a declared box got the
206
+ * file's box, no finding and exit 0 — and the sharper case is the caller who
207
+ * meant to correct a wrong box and believed they had. The file is the record
208
+ * of what was measured; two sources for one value is a question, and rigc
209
+ * refuses it rather than answering it quietly.
210
+ */
211
+ stage?: IngestStage;
212
+ /**
213
+ * The stage a rigc build's model document states (issue #907): its `stage`,
214
+ * or `null` for a rig that declared none. Given, it is read IN PLACE OF the
215
+ * header's `x`, `y`, `width`, `height` — which on a rigc build since #907 are
216
+ * the setup-pose bounding box, not the stage — and everything below reads it
217
+ * as it would read a header stating it (`--stage` beside it is the same
218
+ * refusal). Absent, the header is read, as for an export: an export carries
219
+ * no other box. The caller reads it — `src/` reads no files —
220
+ * (`documentStageBeside` in `src/cli/shared.ts`: the `rigc-compiled/3`
221
+ * document beside the skeleton, only when its digest is that skeleton's).
222
+ */
223
+ documentStage?: IngestStage | null;
224
+ /**
225
+ * The slot whose bounding box carries the stage (issue #1168, `--stage-box
226
+ * <slot>`): a rig built with `skeleton.stageBox` carries its stage there,
227
+ * in the files a consumer ships, while the header carries the setup-pose
228
+ * bounding box. Named, that box is read as the stage — in place of the
229
+ * header's box, as `documentStage` is — and the rebuilt rig spec asks for
230
+ * the same box (`skeleton.stageBox`) instead of transcribing it as an
231
+ * attachment. Every shape `build` could not write back is refused by name
232
+ * (`readStageBox`). Absent, nothing is read from a slot: no box is the stage
233
+ * because of its name.
234
+ */
235
+ stageBox?: string;
236
+ /** The source file's basename, for the provenance note. No path: no leak. */
237
+ source: string;
238
+ /** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
239
+ version: string;
240
+ }
241
+
242
+ export interface IngestResult {
243
+ rig: RigSpec;
244
+ motion: MotionSpec;
245
+ findings: IngestFinding[];
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // JSON narrowing — the input is a file somebody else wrote
250
+ // ---------------------------------------------------------------------------
251
+
252
+ type JsonObject = Record<string, unknown>;
253
+
254
+ function isObj(v: unknown): v is JsonObject {
255
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
256
+ }
257
+
258
+ /** The array at `v`, or an empty one. An absent collection is not a fault here. */
259
+ function arr(v: unknown): unknown[] {
260
+ return Array.isArray(v) ? v : [];
261
+ }
262
+
263
+ /** The object at `v`, or an empty one. */
264
+ function obj(v: unknown): JsonObject {
265
+ return isObj(v) ? v : {};
266
+ }
267
+
268
+ /** `Object.entries` over the OBJECT-valued entries of `v`, in file order. */
269
+ function objEntries(v: unknown): Array<[string, JsonObject]> {
270
+ return Object.entries(obj(v)).filter((entry): entry is [string, JsonObject] => isObj(entry[1]));
271
+ }
272
+
273
+ /** `Object.entries` over the ARRAY-valued entries of `v`, in file order. */
274
+ function arrEntries(v: unknown): Array<[string, unknown[]]> {
275
+ return Object.entries(obj(v)).filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]));
276
+ }
277
+
278
+ /** The numbers at `v`, or undefined. A mixed array is not a number array. */
279
+ function numbers(v: unknown): number[] | undefined {
280
+ if (!Array.isArray(v)) return undefined;
281
+ return v.every((n) => typeof n === 'number') ? (v as number[]) : undefined;
282
+ }
283
+
284
+ function nameOf(v: unknown): string {
285
+ return isObj(v) && typeof v.name === 'string' ? v.name : '';
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // field tables — DERIVED from the rig spec's own key sets, never retyped
290
+ // ---------------------------------------------------------------------------
291
+ //
292
+ // ⭐ `RIG_KEYS` is already the statement of which fields a rig spec holds, and
293
+ // `checkRigSpecKeys` refuses everything outside it. Reading the carry list off
294
+ // that table rather than copying it means a field added to the spec becomes
295
+ // carryable here with no edit, and — the half that matters — a skeleton field
296
+ // that has no rig-spec home is refused BY NAME instead of being dropped in
297
+ // silence. A second list would be two lists that have to agree, which is the
298
+ // defect `RIG_KEYS`'s own comment is about.
299
+
300
+ /** Everything `RIG_KEYS` names for a shape, minus the keys this file handles itself. */
301
+ function carried(shape: keyof typeof RIG_KEYS, ...handled: string[]): string[] {
302
+ return (RIG_KEYS[shape] as readonly string[]).filter((key) => !handled.includes(key));
303
+ }
304
+
305
+ /**
306
+ * Bone fields carried verbatim. Inverts `buildBone` in `compile.ts`.
307
+ *
308
+ * `from` is excluded because it is rigc's own: a bone position taken from a
309
+ * manifest anchor, resolved to `x`/`y` at compile time (`cropPointOf`). A
310
+ * skeleton holds the resolved numbers and nothing else, so carrying them as
311
+ * `x`/`y` is the inversion and a `from` here would be an invention.
312
+ */
313
+ const BONE_FIELDS = carried('RigBone', 'name', 'from');
314
+
315
+ /** Slot fields carried verbatim. Inverts the slot loop in `compile()` step 4. */
316
+ const SLOT_FIELDS = carried('RigSlot', 'name');
317
+
318
+ /** Per constraint type, the fields carried verbatim. Inverts `buildRigConstraint`. */
319
+ const CONSTRAINT_FIELDS: Record<string, string[]> = {
320
+ ik: carried('RigIkConstraint', 'name', 'type'),
321
+ transform: carried('RigTransformConstraint', 'name', 'type'),
322
+ path: carried('RigPathConstraint', 'name', 'type'),
323
+ physics: carried('RigPhysicsConstraint', 'name', 'type'),
324
+ slider: carried('RigSliderConstraint', 'name', 'type'),
325
+ };
326
+
327
+ /**
328
+ * The physics constraints that drive nothing, by name, each with the component
329
+ * fields it DOES state — every one of them at most 0 (issue #731).
330
+ *
331
+ * 🔑 The question is the core's `physicsDrives` (`src/core/constraints_physics.ts`,
332
+ * issue #1015): a component above 0 drives its part of the step and nothing
333
+ * else does, so a constraint with none of `PHYSICS_COMPONENTS` above 0 moves no
334
+ * bone — `PhysicsConstraint.update` applies a part only on that test
335
+ * (`PhysicsConstraint.js:112`). An unstated component is the parser's 0. `build`
336
+ * refuses exactly that shape by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the
337
+ * gate — so carrying one through made the decompiled spec of a file an editor
338
+ * exports unbuildable as a whole, over a constraint that did nothing in it.
339
+ *
340
+ * ⚠️ A component that is present and NOT a number is not inert: the parser takes
341
+ * it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
342
+ * constraint is carried and rigc's own parser says what is wrong with it.
343
+ */
344
+ function inertPhysics(root: JsonObject): Map<string, string[]> {
345
+ const out = new Map<string, string[]>();
346
+ for (const raw of arr(root.constraints)) {
347
+ const constraint = obj(raw);
348
+ if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
349
+ const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
350
+ if (!stated.every((field) => typeof constraint[field] === 'number')) continue;
351
+ const value = (field: (typeof PHYSICS_COMPONENTS)[number]): number => (constraint[field] === undefined ? 0 : (constraint[field] as number));
352
+ const drives = physicsDrives({ x: value('x'), y: value('y'), rotate: value('rotate'), shearX: value('shearX'), scaleX: value('scaleX') });
353
+ if (drives.x || drives.y || drives.rotateOrShearX || drives.scaleX) continue;
354
+ out.set(
355
+ constraint.name,
356
+ stated.map((field) => `${field} ${String(constraint[field])}`),
357
+ );
358
+ }
359
+ return out;
360
+ }
361
+
362
+ /**
363
+ * The two kinds `A47` / `A48` ask the muted-at-rest question of. An alias rather
364
+ * than the union written at each use, because `IG25`'s scan resolves a composed
365
+ * finding code against the nearest `<name>: '…' | '…'` above it, and `type:` is
366
+ * the identifier `ATTACHMENT_<TYPE>` composes from further down.
367
+ */
368
+ type MixedConstraintKind = 'ik' | 'transform';
369
+
370
+ /**
371
+ * The ik and transform constraints this file rests muted and never switches on
372
+ * — the shape `A47` / `A48` refuse — with the mixes each one reads (issue #784).
373
+ *
374
+ * 🔑 **Read the way the gate reads it, from the raw JSON.** This module does not
375
+ * link the runtime, so the defaults are the parser's own, spelled here: an ik
376
+ * `mix` is 1 when absent, on the constraint and on every key; a transform mix
377
+ * is 1 when absent except `mixY`, which is the same object's `mixX`, and
378
+ * `mixScaleY`, which is its `mixScaleX` (`SkeletonJson.js`, every
379
+ * `getValue(…, 1)`). A transform reads only the mixes of the `to` properties it
380
+ * declares, and one that declares none is `A48`'s other sentence, which no
381
+ * declaration answers — so it is not a candidate. Live is `!== 0`, the
382
+ * runtime's test. A key lifts a mix when the value it states is live, or when
383
+ * its Bezier handles for that channel are — the curve the runtime interpolates
384
+ * through (`curveChannelValues` in `validate.ts`), which is how a 0 → 0 pair
385
+ * with raised handles moves the bones.
386
+ *
387
+ * ⚠️ A disagreement with the gate is loud either way, never silent: an entry on
388
+ * a constraint the gate reads as live is refused by name (declared but already
389
+ * switched on), and a muted one this misses is `A47` / `A48` refusing it.
390
+ */
391
+ function consumerDrivenCandidates(root: JsonObject): Array<{ type: MixedConstraintKind; name: string; reads: string[] }> {
392
+ const TO_MIX: Record<string, string> = {
393
+ rotate: 'mixRotate',
394
+ x: 'mixX',
395
+ y: 'mixY',
396
+ scaleX: 'mixScaleX',
397
+ scaleY: 'mixScaleY',
398
+ shearY: 'mixShearY',
399
+ };
400
+ /** Frame order of a transform timeline's six curve channels. */
401
+ const TRANSFORM_ORDER = ['mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY'];
402
+ const num = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback);
403
+ const transformMixes = (m: JsonObject): Record<string, number> => {
404
+ const mixX = num(m.mixX, 1);
405
+ const mixScaleX = num(m.mixScaleX, 1);
406
+ return {
407
+ mixRotate: num(m.mixRotate, 1),
408
+ mixX,
409
+ mixY: num(m.mixY, mixX),
410
+ mixScaleX,
411
+ mixScaleY: num(m.mixScaleY, mixScaleX),
412
+ mixShearY: num(m.mixShearY, 1),
413
+ };
414
+ };
415
+ /** The Bezier handle values of channel `c` on a key's `curve`; none when it is linear or stepped. */
416
+ const handles = (key: JsonObject, c: number): number[] =>
417
+ Array.isArray(key.curve) ? [key.curve[4 * c + 1], key.curve[4 * c + 3]].filter((v): v is number => typeof v === 'number') : [];
418
+ const keysOf = (kind: MixedConstraintKind, name: string): JsonObject[] =>
419
+ Object.values(obj(root.animations)).flatMap((animation) => arr(obj(obj(animation)[kind])[name]).map(obj));
420
+ const out: Array<{ type: MixedConstraintKind; name: string; reads: string[] }> = [];
421
+ for (const raw of arr(root.constraints)) {
422
+ const constraint = obj(raw);
423
+ if (typeof constraint.name !== 'string') continue;
424
+ if (constraint.type === 'ik') {
425
+ if (num(constraint.mix, 1) !== 0) continue;
426
+ const lifted = keysOf('ik', constraint.name).some((key) => num(key.mix, 1) !== 0 || handles(key, 0).some((v) => v !== 0));
427
+ if (!lifted) out.push({ type: 'ik', name: constraint.name, reads: ['mix'] });
428
+ } else if (constraint.type === 'transform') {
429
+ const reads = [
430
+ ...new Set(
431
+ Object.values(obj(constraint.properties)).flatMap((from) => Object.keys(obj(obj(from).to)).map((to) => TO_MIX[to])),
432
+ ),
433
+ ]
434
+ .filter((mix): mix is string => mix !== undefined)
435
+ .sort((a, b) => TRANSFORM_ORDER.indexOf(a) - TRANSFORM_ORDER.indexOf(b));
436
+ if (reads.length === 0) continue;
437
+ const setup = transformMixes(constraint);
438
+ if (reads.some((mix) => setup[mix] !== 0)) continue;
439
+ const lifted = keysOf('transform', constraint.name).some((key) => {
440
+ const values = transformMixes(key);
441
+ return reads.some((mix) => values[mix] !== 0 || handles(key, TRANSFORM_ORDER.indexOf(mix)).some((v) => v !== 0));
442
+ });
443
+ if (!lifted) out.push({ type: 'transform', name: constraint.name, reads });
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
449
+ /** What omitting one inert physics constraint took with it, for its finding. */
450
+ interface PhysicsOmission {
451
+ /** `animation "<a>" <timeline>`, in file order. */
452
+ timelines: string[];
453
+ /** The skins whose `physics` member list named it. */
454
+ skins: string[];
455
+ /** Per animation, the latest key time on an omitted timeline of this constraint. */
456
+ lastKeys: Map<string, number>;
457
+ }
458
+
459
+ /**
460
+ * The per-skin member lists (`SkeletonJson` reads them before `attachments`).
461
+ *
462
+ * `RIG_KEYS.RigSkinEntry` is `attachments` plus the five constraint lists plus
463
+ * `bones`; the long form of a skin is exactly those, so the list is that set
464
+ * minus the table itself.
465
+ */
466
+ const SKIN_LISTS = carried('RigSkinEntry', 'attachments');
467
+
468
+ /**
469
+ * One value-track channel: the JSON field, and what the PARSER reads where a key
470
+ * omits it.
471
+ *
472
+ * ⚠️ A number is a constant default; a STRING is another field of the same key,
473
+ * which is how `mixY` works (`SkeletonJson:988` — it defaults to that key's own
474
+ * `mixX`, not to 1). The same two-shaped table as `CONSTRAINT_TIMELINES`'s
475
+ * `inheritsFrom`, and for the same reason.
476
+ */
477
+ type TrackShape = Array<[field: string, dflt: number | string]>;
478
+
479
+ /**
480
+ * Bone timeline shapes. Inverts `BONE_TRACKS` + `compileValueTrack`.
481
+ *
482
+ * 🚨 The defaults matter more than they look, and they are not all the same:
483
+ * Spine omits a field that equals the SETUP value, `translate` reads 0 there and
484
+ * `scale` reads 1. A decompiler that filled every omission with 0 would collapse
485
+ * every scale key it read, silently.
486
+ */
487
+ const BONE_TRACKS: Record<string, TrackShape> = {
488
+ translate: [['x', 0], ['y', 0]],
489
+ translatex: [['value', 0]],
490
+ translatey: [['value', 0]],
491
+ scale: [['x', 1], ['y', 1]],
492
+ scalex: [['value', 1]],
493
+ scaley: [['value', 1]],
494
+ shear: [['x', 0], ['y', 0]],
495
+ shearx: [['value', 0]],
496
+ sheary: [['value', 0]],
497
+ rotate: [['value', 0]],
498
+ };
499
+
500
+ /**
501
+ * The bone timeline whose key holds a NAME rather than numbers — `inherit`,
502
+ * `{ time, inherit }`, stepped by the format (its reader builds no curve). The
503
+ * field and the default `SkeletonJson`'s `inherit` branch reads where a key
504
+ * omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
505
+ * `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
506
+ */
507
+ const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
508
+
509
+ /** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
510
+ const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
511
+
512
+ /**
513
+ * The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
514
+ * `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
515
+ *
516
+ * 🚨 The defaults are the parser's per-key ones and they are **0 on six of the
517
+ * eight**, which is not where a reader looks for them: the constraint's own
518
+ * defaults (`inertia` 0.5, `strength` 100, `damping` 0.85, `mass` 1) sit in the
519
+ * same file at `:306-312` and belong to the constraint, not to a key. A
520
+ * decompiler that filled an omitted `damping` key with 0.85 would write a spec
521
+ * that plays a different animation from the one it read, and every gate would
522
+ * call it green. Copied off `SkeletonJson.js:1062` and `:1090`, not assumed.
523
+ */
524
+ const PHYSICS_TRACKS: Record<string, TrackShape> = {
525
+ inertia: [['value', 0]],
526
+ strength: [['value', 0]],
527
+ damping: [['value', 0]],
528
+ mass: [['value', 0]],
529
+ wind: [['value', 0]],
530
+ gravity: [['value', 0]],
531
+ mix: [['value', 1]],
532
+ reset: [],
533
+ };
534
+
535
+ /** `mix` is three values in ONE key — `PATH_TRACKS` in `compile.ts`. */
536
+ const PATH_TRACKS: Record<string, TrackShape> = {
537
+ position: [['value', 0]],
538
+ spacing: [['value', 0]],
539
+ mix: [['mixRotate', 1], ['mixX', 1], ['mixY', 'mixX']],
540
+ };
541
+
542
+ /** ⚠️ `time`'s per-key default is **1**, not 0 (`:1121`). Copied, not assumed. */
543
+ const SLIDER_TRACKS: Record<string, TrackShape> = { time: [['value', 1]], mix: [['value', 1]] };
544
+
545
+ /**
546
+ * The `ik` and `transform` key fields and the value the PARSER reads where a key
547
+ * omits one — `CONSTRAINT_TIMELINES` in `compile.ts`, channels then flags.
548
+ *
549
+ * ⚠️ `mixY` is the one field whose default is not a constant: it is the same
550
+ * key's own `mixX` (`SkeletonJson:988`), which is why it is spelled here as a
551
+ * field name rather than a number.
552
+ */
553
+ const IK_KEY_DEFAULTS: Record<string, number | boolean> = {
554
+ mix: 1,
555
+ softness: 0,
556
+ bendPositive: true,
557
+ compress: false,
558
+ stretch: false,
559
+ };
560
+ const TRANSFORM_KEY_DEFAULTS: Record<string, number | boolean | string> = {
561
+ mixRotate: 1,
562
+ mixX: 1,
563
+ mixY: 'mixX',
564
+ mixScaleX: 1,
565
+ mixScaleY: 1,
566
+ mixShearY: 1,
567
+ };
568
+ /** The three ik booleans, which `compileConstraintTrack` stamps from the rig. */
569
+ const IK_FLAGS = ['bendPositive', 'compress', 'stretch'];
570
+
571
+ /**
572
+ * The animation groups the motion spec carries. Anything else is a blocker.
573
+ *
574
+ * ⚠️ This said *"the groups `readAnimation` reads"* until issue #675, and the
575
+ * `ANIMATION_GROUP` detail below said it to the reader. It is not the same set:
576
+ * `SkeletonJson.readAnimation` reads `drawOrderFolder` too and builds a
577
+ * `DrawOrderFolderTimeline` from it, so on that one name the sentence told an
578
+ * author the parser ignores something it plays. What is true either way is the
579
+ * half that decides the rebuild — the motion spec has no home for it — so that
580
+ * is what both the list and the finding now say.
581
+ */
582
+ const ANIMATION_GROUPS = ['bones', 'slots', 'ik', 'transform', 'path', 'physics', 'slider', 'attachments', 'drawOrder', 'events'];
583
+
584
+ /** The header fields rigc writes that no rig spec field holds. */
585
+ const HEADER_REDERIVED = ['spine'];
586
+
587
+ /** The attachment types this module inverts. Everything else is refused by name. */
588
+ const ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'clipping', 'path'];
589
+
590
+ /**
591
+ * The geometry keys a LINKED mesh may state and the parser never reads
592
+ * (issue #710).
593
+ *
594
+ * `readAttachment` returns from the `source` branch at `SkeletonJson.js:586`,
595
+ * before `map.uvs` is touched, so a link carrying any of these is a file that
596
+ * says one mesh while every runtime draws its source's. The rig spec cannot hold
597
+ * them either — `buildRigLinkedMesh` refuses geometry on a link by name — so a
598
+ * rebuild that carried one would be a spec `build` refuses, and a rebuild that
599
+ * dropped it in silence would be this module normalising somebody's file without
600
+ * saying so.
601
+ *
602
+ * ⚠️ A second list beside `src/validate.ts`'s, deliberately: that module links
603
+ * spine-core and this one must not, so importing it would pull the runtime into
604
+ * every `ingest`. What holds the two equal is a RUN rather than a shared
605
+ * constant — one forged skeleton through both, with the keys `A44` names and the
606
+ * keys this finding names compared as sets.
607
+ */
608
+ const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'];
609
+
610
+ /**
611
+ * The slot timelines the motion spec carries — `compileTrack`'s own table,
612
+ * rather than a second list of the same two names.
613
+ *
614
+ * ⚠️ It was that second list until issue #650, spelled `['rgba', 'attachment']`
615
+ * with a comment saying where it had been copied from. The copy was true, which
616
+ * is the point: the emitter had no list at all — one `if` and a fall-through —
617
+ * so this module's blocker was the only place in `src/` that said what a slot
618
+ * track may be, and it said it about a compiler that accepted anything. Now
619
+ * there is one list and both sides read it.
620
+ */
621
+ const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
622
+
623
+ /**
624
+ * The slot timelines the FORMAT has and the motion spec has no track for —
625
+ * **none** since issue #730 spelled `rgb`, `alpha` and `rgb2`, which were the
626
+ * last three. It stays derived rather than deleted, because an empty
627
+ * difference of two tables is a measurement and a deleted one is a claim: the
628
+ * day the format grows a seventh slot timeline, this is where it appears, and
629
+ * the blocker's sentence names it without an edit.
630
+ *
631
+ * ⭐ Both sides are derived, and that is the whole reason it exists rather than
632
+ * being spelled into the blocker's sentence. `docs/INGEST.md`'s row for this
633
+ * code named `rgba2` among the timelines nobody carries for as long as that was
634
+ * true, and went on saying it after issue #690 made it false — a hand-kept list
635
+ * beside a derived one, which is the shape this repository refuses everywhere
636
+ * else. The format's own list is `CHANNELS_BY_KIND.slot`, the emitter's is
637
+ * `SLOT_TRACKS`, and the difference is the answer.
638
+ */
639
+ export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((name) => !(name in EMITTED_SLOT_TRACKS));
640
+
641
+ /**
642
+ * Everything this module has a branch for, as the branches themselves state it.
643
+ *
644
+ * ⭐ It exists so that a gate can ask the question a suite cannot answer from a
645
+ * list somebody typed: **is every construct `ingest` claims to carry actually
646
+ * exercised by a rig somebody builds?** A decompiler branch no rig reaches is a
647
+ * branch nobody has seen work, which is this repository's own definition of not
648
+ * a gate — and the vocabulary has to come from here, because a second copy in
649
+ * `selftest.ts` would go stale in exactly the direction that hides the hole.
650
+ */
651
+ export const INGEST_VOCABULARY = {
652
+ attachments: ATTACHMENT_TYPES,
653
+ constraints: Object.keys(CONSTRAINT_FIELDS),
654
+ boneTracks: BONE_TRACK_NAMES,
655
+ slotTracks: SLOT_TRACKS,
656
+ path: Object.keys(PATH_TRACKS),
657
+ physics: Object.keys(PHYSICS_TRACKS),
658
+ slider: Object.keys(SLIDER_TRACKS),
659
+ animationGroups: ANIMATION_GROUPS,
660
+ } as const satisfies Record<string, readonly string[]>;
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // the inversions
664
+ // ---------------------------------------------------------------------------
665
+
666
+ /**
667
+ * Spine's flat weight run back into one `{bone, x, y, weight}` list per vertex.
668
+ *
669
+ * 🔒 Inverts `encodeNamedWeights`, and the inversion is **by name** for exactly
670
+ * the reason that function encodes by name: the run holds positions in the
671
+ * EMITTED bone array, a list no spec writes, so a decompiled `vertices` run
672
+ * would rebind every vertex the moment a bone moved in the array (issue #45).
673
+ * The names are the join key on both sides.
674
+ */
675
+ function decodeWeights(vertices: readonly number[], boneNames: readonly string[]): Array<Array<Record<string, unknown>>> {
676
+ const out: Array<Array<Record<string, unknown>>> = [];
677
+ let i = 0;
678
+ while (i < vertices.length) {
679
+ const count = vertices[i++];
680
+ const vertex: Array<Record<string, unknown>> = [];
681
+ for (let k = 0; k < count; k++) {
682
+ vertex.push({ bone: boneNames[vertices[i]], x: vertices[i + 1], y: vertices[i + 2], weight: vertices[i + 3] });
683
+ i += 4;
684
+ }
685
+ out.push(vertex);
686
+ }
687
+ return out;
688
+ }
689
+
690
+ /**
691
+ * `"rrggbbaa"` back to `[r, g, b, a]` in 0..1. Inverts `rgbaHex`.
692
+ *
693
+ * ⚠️ One byte per channel is all the file holds, so this is exact in the only
694
+ * direction that matters: the rebuild quantises the same floats to the same
695
+ * bytes. It is not a recovery of whatever the original author typed.
696
+ */
697
+ function hexToRgba(hex: string): number[] {
698
+ return [0, 2, 4, 6].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
699
+ }
700
+
701
+ /**
702
+ * The three-channel form, which is what a two-colour key's `dark` is written as
703
+ * — `rrggbb` and no alpha, because `RGBA2Timeline` stores three dark channels
704
+ * and the fourth a shader reads there is the premultiply flag rather than a
705
+ * colour (`compile.ts`'s `rgba2Hex` states the same fact from the emit side).
706
+ */
707
+ function hexToRgb(hex: string): number[] {
708
+ return [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
709
+ }
710
+
711
+ /**
712
+ * Read a skeleton, write the two specs that rebuild it.
713
+ *
714
+ * Pure: the same skeleton and the same options give the same specs, every time.
715
+ * The two returned specs have been through `parseRigSpec` and `parseMotionSpec`
716
+ * before they leave — a decompiler that hands back something the compiler's own
717
+ * parser would refuse has produced a file nobody can use, and saying so here
718
+ * names the decompiler instead of leaving `build` to name the file.
719
+ */
720
+ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
721
+ const findings: IngestFinding[] = [];
722
+ const note = (kind: IngestFindingKind, code: string, where: string, detail: string): void => {
723
+ findings.push({ code, where, detail, kind });
724
+ };
725
+
726
+ const root = obj(skeleton);
727
+ const boneNames: string[] = arr(root.bones).map(nameOf);
728
+
729
+ // -- the generation -------------------------------------------------------
730
+ // 🚨 First, because every walk below reads the file as 4.3 and a file from
731
+ // another generation is one this module cannot honestly claim to have read
732
+ // (issue #706 item 3). It records; it does not refuse — see `readGeneration`.
733
+ readGeneration(root, note);
734
+
735
+ // -- header ---------------------------------------------------------------
736
+ // 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
737
+ // stage is the only value in this whole module that a skeleton cannot answer
738
+ // for. Since #578 a rig spec can SAY that a skeleton declares no stage, and
739
+ // since #714 this is where a file that declares none is carried as saying so.
740
+ const stageBox = opts.stageBox === undefined ? null : readStageBox(root, opts.stageBox, note);
741
+ const rigHeader = ingestHeader(obj(root.skeleton), opts, note, stageBox);
742
+
743
+ // -- physics constraints that drive nothing (issue #731) ------------------
744
+ // Read before the skins, because a skin's `physics` member list is one of the
745
+ // three places such a constraint is named and each has to let go of it.
746
+ const inert = inertPhysics(root);
747
+ const omissions = new Map<string, PhysicsOmission>();
748
+ const omission = (name: string): PhysicsOmission => {
749
+ const found = omissions.get(name);
750
+ if (found !== undefined) return found;
751
+ const made: PhysicsOmission = { timelines: [], skins: [], lastKeys: new Map() };
752
+ omissions.set(name, made);
753
+ return made;
754
+ };
755
+
756
+ // -- bones ----------------------------------------------------------------
757
+ // Inverts `buildBone`, which copies every declared field and omits the rest.
758
+ const bones = arr(root.bones).map((raw) => {
759
+ const bone = obj(raw);
760
+ const out: JsonObject = { name: bone.name };
761
+ for (const field of BONE_FIELDS) if (bone[field] !== undefined) out[field] = bone[field];
762
+ for (const key of Object.keys(bone)) {
763
+ if (key === 'name' || BONE_FIELDS.includes(key)) continue;
764
+ note('blocker', 'BONE_FIELD', `bone "${nameOf(bone)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
765
+ }
766
+ return out;
767
+ });
768
+
769
+ // -- slots ----------------------------------------------------------------
770
+ // A slot some skin fills but that shows nothing in the setup pose carries NO
771
+ // `attachment` field, and `build` refuses a filled slot with no setup pose
772
+ // ("the compiler will not guess one"). The skeleton does state it: an absent
773
+ // `attachment` on a filled slot means "show nothing", which the rig spec
774
+ // spells `null`. Transcribing an absence is not inventing a value.
775
+ const filled = new Set<string>();
776
+ for (const skin of arr(root.skins)) {
777
+ for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
778
+ if (Object.keys(placeholders).length) filled.add(slot);
779
+ }
780
+ }
781
+ const slots = arr(root.slots).map((raw) => {
782
+ const slot = obj(raw);
783
+ const out: JsonObject = { name: slot.name };
784
+ for (const field of SLOT_FIELDS) if (slot[field] !== undefined) out[field] = slot[field];
785
+ if (out.attachment === undefined && filled.has(nameOf(slot))) out.attachment = null;
786
+ for (const key of Object.keys(slot)) {
787
+ if (key === 'name' || SLOT_FIELDS.includes(key)) continue;
788
+ note('blocker', 'SLOT_FIELD', `slot "${nameOf(slot)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
789
+ }
790
+ return out;
791
+ });
792
+
793
+ // -- skins and attachments ------------------------------------------------
794
+ const skins: JsonObject = {};
795
+ for (const skin of arr(root.skins)) {
796
+ const entry = obj(skin);
797
+ const table: JsonObject = {};
798
+ for (const [slot, placeholders] of objEntries(entry.attachments)) {
799
+ const perSlot: JsonObject = {};
800
+ for (const [placeholder, att] of Object.entries(placeholders)) {
801
+ // The stage box is the rebuilt spec's `skeleton.stageBox`, which `build` writes from the stage — not an attachment to transcribe (issue #1168).
802
+ if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot && placeholder === stageBox.attachment) continue;
803
+ perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
804
+ where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
805
+ });
806
+ }
807
+ if (stageBox !== null && nameOf(entry) === 'default' && slot === stageBox.slot) continue;
808
+ table[slot] = perSlot;
809
+ }
810
+ const lists: JsonObject = {};
811
+ let anyList = false;
812
+ for (const list of SKIN_LISTS) {
813
+ if (entry[list] !== undefined) {
814
+ let members = entry[list];
815
+ if (list === 'physics' && Array.isArray(members)) {
816
+ const named = members.filter((member): member is string => typeof member === 'string' && inert.has(member));
817
+ for (const member of named) omission(member).skins.push(nameOf(entry));
818
+ members = members.filter((member) => !(typeof member === 'string' && inert.has(member)));
819
+ // A list that named nothing BUT omitted constraints goes with them: an
820
+ // empty list and an absent one read the same, and only one of them is
821
+ // what the rebuild writes for a skin with no physics member.
822
+ if (named.length > 0 && (members as unknown[]).length === 0) continue;
823
+ }
824
+ lists[list] = members;
825
+ anyList = true;
826
+ }
827
+ }
828
+ // The long form only where the skin activates something; otherwise the short
829
+ // form, which is what every rig in this tree writes and what `splitRigSkin`
830
+ // reads back as the bare attachment table.
831
+ skins[nameOf(entry)] = anyList ? { ...lists, attachments: table } : table;
832
+ }
833
+
834
+ // -- constraints ----------------------------------------------------------
835
+ // Inverts `buildRigConstraint`. 4.3 puts every type in ONE array and branches
836
+ // on `type`, so an unknown `type` is refused here for the same reason the
837
+ // parser's silence about it is assertion A01: it would simply vanish.
838
+ const constraints: JsonObject[] = [];
839
+ for (const raw of arr(root.constraints)) {
840
+ const constraint = obj(raw);
841
+ const type = typeof constraint.type === 'string' ? constraint.type : '';
842
+ const fields = CONSTRAINT_FIELDS[type];
843
+ const who = `constraint "${nameOf(constraint)}"`;
844
+ if (fields === undefined) {
845
+ note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
846
+ continue;
847
+ }
848
+ // Omitted rather than carried, and said below once the timelines it takes
849
+ // with it are known (`PHYSICS_DRIVES_NOTHING`).
850
+ if (type === 'physics' && typeof constraint.name === 'string' && inert.has(constraint.name)) continue;
851
+ const out: JsonObject = { name: constraint.name, type };
852
+ for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
853
+ for (const key of Object.keys(constraint)) {
854
+ if (key === 'name' || key === 'type' || fields.includes(key)) continue;
855
+ note('blocker', 'CONSTRAINT_FIELD', `${who} (${type})`, `field "${key}" has no rig-spec field, so it is dropped`);
856
+ }
857
+ constraints.push(out);
858
+ }
859
+
860
+ // -- animations -----------------------------------------------------------
861
+ const animations: JsonObject = {};
862
+ for (const [animName, raw] of objEntries(root.animations)) {
863
+ animations[animName] = ingestAnimation(animName, raw, root, note, inert, (name, property, lastKey) => {
864
+ const one = omission(name);
865
+ one.timelines.push(`animation "${animName}" ${property}`);
866
+ one.lastKeys.set(animName, Math.max(one.lastKeys.get(animName) ?? 0, lastKey));
867
+ });
868
+ }
869
+
870
+ // One finding per inert constraint, in the file's own order, naming what went
871
+ // with it. ⚠️ An animation's duration is the largest key time it has LEFT, so
872
+ // an omitted timeline that held the last key shortens the rebuilt animation —
873
+ // the one place this omission is not a no-op, and the detail says so there.
874
+ for (const [name, stated] of inert) {
875
+ const gone = omission(name);
876
+ const shortened = [...gone.lastKeys]
877
+ .map(([animName, lastKey]) => [animName, lastKey, Number(obj(animations[animName]).duration)] as const)
878
+ .filter(([, lastKey, duration]) => lastKey > duration)
879
+ .map(
880
+ ([animName, lastKey, duration]) =>
881
+ `animation "${animName}" had its last key at ${lastKey}s on one of them, so the rebuilt animation ends at ${duration}s`,
882
+ );
883
+ note(
884
+ 'lossy',
885
+ 'PHYSICS_DRIVES_NOTHING',
886
+ `constraint "${name}" (physics)`,
887
+ `drives no component: ${PHYSICS_COMPONENTS.join(', ')} are ` +
888
+ (stated.length ? `absent or at most 0 (it states ${stated.join(', ')})` : 'all absent') +
889
+ ', and `PhysicsConstraint.update` applies one only above 0, so it moves no bone and `build` would refuse it ' +
890
+ 'by name (A23_PHYSICS_CONSTRAINT_EFFECTIVE). The rig spec omits it' +
891
+ (gone.timelines.length
892
+ ? `, and with it the ${gone.timelines.length} timeline(s) keyed to it, which would name a constraint the ` +
893
+ `rebuild does not have: ${gone.timelines.join('; ')}`
894
+ : '; no timeline keys it') +
895
+ (gone.skins.length ? `; it is taken off the physics list of skin(s) ${gone.skins.map((skin) => `"${skin}"`).join(', ')}` : '') +
896
+ (shortened.length
897
+ ? `. One thing does move, because a duration is the last key an animation has left: ${shortened.join('; ')}`
898
+ : '. The rebuild differs from the source by exactly these no-ops'),
899
+ );
900
+ }
901
+
902
+ // -- constraints the consumer drives (issue #784) --------------------------
903
+ // A constraint resting muted that no animation switches on is exactly what
904
+ // `A47` / `A48` refuse, and the export cannot say whether it is a leftover or
905
+ // a dial a game turns from code: the two are the same bytes. The rebuild
906
+ // reads it as the consumer's — the reading under which the file is correct —
907
+ // and says so twice: in the rig spec, as the declaration the gate reads, and
908
+ // as a finding naming the constraint, so the author sees what the rebuild is
909
+ // claiming. It is a `judgement` for `DURATION`'s reason: a statement the
910
+ // skeleton does not carry, made and printed rather than hidden, with nothing
911
+ // lost — the rebuilt skeleton is the same bytes either way.
912
+ const carried = new Set(constraints.map((c) => constraintAt(String(c.type), String(c.name))));
913
+ const consumerDrivenMix: JsonObject[] = [];
914
+ const animationCount = Object.keys(obj(root.animations)).length;
915
+ for (const { type, name, reads } of consumerDrivenCandidates(root)) {
916
+ if (!carried.has(constraintAt(type, name))) continue;
917
+ const assertion = type === 'ik' ? 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT' : 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
918
+ consumerDrivenMix.push({
919
+ constraint: name,
920
+ type,
921
+ why:
922
+ 'the source skeleton rests it muted and no animation keys its mix above 0; `rigc ingest` reads it as a mix ' +
923
+ 'the consumer sets, which an export cannot state. Delete this entry if it is a leftover',
924
+ });
925
+ note(
926
+ 'judgement',
927
+ 'CONSUMER_DRIVEN_MIX',
928
+ `constraint "${name}" (${type})`,
929
+ `rests at ${reads.map((mix) => `${mix} 0`).join(', ')} and none of the ${animationCount} ` +
930
+ `animation${animationCount === 1 ? '' : 's'} keys its mix above 0, so nothing in this file ever switches it ` +
931
+ 'on — and the file cannot say whether that is a leftover or a mix a game sets from code. `build` refuses the ' +
932
+ `shape by name (${assertion}) unless the rig spec says which, so the rig spec now says the consumer drives ` +
933
+ 'it, in invariants.consumerDrivenMix, and the gate SKIPs it by name rather than measuring it. If it is a ' +
934
+ `leftover, delete the entry and rest ${type === 'ik' ? 'its mix' : 'a mix it reads'} above 0 or remove the ` +
935
+ 'constraint, and the gate measures it again',
936
+ );
937
+ }
938
+
939
+ // -- assemble -------------------------------------------------------------
940
+ const rig: JsonObject = {
941
+ spec: RIG_SPEC_VERSION,
942
+ name: opts.name,
943
+ note: provenanceNote(opts, 'rig', consumerDrivenMix.length > 0),
944
+ };
945
+ if (Object.keys(rigHeader).length) rig.skeleton = rigHeader;
946
+ // Between `skeleton` and `bones`, which is where `RIG_KEYS.RigSpec` puts it —
947
+ // this file's key order is the spec's declared order and nothing sorts it.
948
+ if (opts.images !== undefined) rig.images = opts.images;
949
+ rig.bones = bones;
950
+ rig.slots = slots;
951
+ if (Object.keys(skins).length) rig.skins = skins;
952
+ if (constraints.length) rig.constraints = constraints;
953
+ if (isObj(root.events)) rig.events = { ...root.events };
954
+ // `invariants` is absent but for one field. A skeleton states no invariant,
955
+ // and INGEST §2.1 already says what to do about that: leave the block out,
956
+ // because an assertion with nothing to measure reports SKIP and never a pass.
957
+ // Writing an invariant that turns a check ON here would be certifying a rig
958
+ // nobody measured. `consumerDrivenMix` is the other direction — it turns two
959
+ // checks OFF for the constraints named above, which certifies nothing: what it
960
+ // buys is a SKIP by name, and a finding says each one out loud (issue #784).
961
+ if (consumerDrivenMix.length) rig.invariants = { consumerDrivenMix };
962
+
963
+ const motion: JsonObject = {
964
+ spec: MOTION_SPEC_VERSION,
965
+ archetype: opts.name,
966
+ cut: opts.name,
967
+ note: provenanceNote(opts, 'motion'),
968
+ // Empty on purpose: every curve below is written as a RAW `curve` array.
969
+ // A named easing says "this shape, wherever it is used" and an export carries
970
+ // a different bezier per key per channel, so there is no named easing to
971
+ // recognise — only a shape to copy. `easingCurve`'s output is what a raw
972
+ // curve holds, which is why the rebuild is byte-identical either way.
973
+ easings: {},
974
+ animations,
975
+ };
976
+
977
+ // 🔒 Through the tree's own parsers before they leave. `parseRigSpec` and
978
+ // `parseMotionSpec` are what `build` reads these files with, so a spec this
979
+ // module could produce and `build` would refuse is named here, at the
980
+ // decompiler, rather than three commands later at the file.
981
+ //
982
+ // ⚠️ And the refusal is a FINDING (issue #692). It is the one place in this
983
+ // module where a reader's exit code could come from something other than the
984
+ // findings list, which made it the one shape the census behind the finding
985
+ // codes could not count.
986
+ try {
987
+ return {
988
+ rig: parseRigSpec(rig, `ingest(${opts.source}): rig spec`),
989
+ motion: parseMotionSpec(motion, `ingest(${opts.source}): motion spec`),
990
+ findings,
991
+ };
992
+ } catch (err) {
993
+ if (!(err instanceof CompileError)) throw err;
994
+ note(
995
+ 'blocker',
996
+ 'SPEC_REFUSED',
997
+ `the specs written from "${opts.source}"`,
998
+ `${err.message} — rigc's own parser refuses what this run wrote, so \`build\` will refuse it too. Both files ` +
999
+ 'are on disk so the sentence can be read against the skeleton it came from; nothing rebuilds this ' +
1000
+ 'skeleton until the shape it names has a spelling in the spec',
1001
+ );
1002
+ throw new IngestSpecRefused(err.message, findings, rig, motion);
1003
+ }
1004
+ }
1005
+
1006
+ type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
1007
+
1008
+ /**
1009
+ * The generation of the data this module inverts, read off the version the
1010
+ * emitter writes rather than typed beside it.
1011
+ *
1012
+ * ⚠️ `null` here would be a compiler emitting a version string this repository's
1013
+ * own detector cannot read, and the comparison below is written so that it
1014
+ * blocks every file rather than none — a reader that cannot say what it reads
1015
+ * cannot certify anything. `runGenerationSuite`'s positive control is what says
1016
+ * out loud that it is not null.
1017
+ */
1018
+ const READER_GENERATION = spineGeneration(SPINE_VERSION);
1019
+
1020
+ /** Up to six names, so one finding cannot print a hundred. */
1021
+ function spellSome(names: readonly string[]): string {
1022
+ const shown = names.slice(0, 6).map((name) => `"${name}"`).join(', ');
1023
+ return names.length > 6 ? `${shown} +${names.length - 6} more` : shown;
1024
+ }
1025
+
1026
+ /**
1027
+ * What a 4.3 reader loses on THIS file, counted on this file.
1028
+ *
1029
+ * 🚨 Three shapes, and they are the three #706 measured rather than three this
1030
+ * module thought of: constraints parked in the top-level arrays 4.3 folded away
1031
+ * (row 1 — 1,302 shipped skeletons parsed and loaded 0 of 8,672 constraints),
1032
+ * bones carrying the key 4.3 renamed (row 6), and physics constraints omitting a
1033
+ * field whose default is not the same number in 4.2 as in 4.3 (row 4).
1034
+ *
1035
+ * ⚠️ It is a MEASUREMENT and not an inventory: a construct none of the three
1036
+ * describes is lost without being counted here, which is why the empty case says
1037
+ * so rather than saying nothing was lost.
1038
+ */
1039
+ function generationLosses(root: JsonObject): string[] {
1040
+ const out: string[] = [];
1041
+
1042
+ const parked = TOPLEVEL_CONSTRAINT_ARRAYS.map((kind) => [kind, arr(root[kind]).length] as const).filter(
1043
+ ([, count]) => count > 0,
1044
+ );
1045
+ const parkedTotal = parked.reduce((total, [, count]) => total + count, 0);
1046
+ if (parkedTotal > 0) {
1047
+ out.push(
1048
+ `${parkedTotal} constraint(s) sit in top-level arrays (${parked.map(([kind, count]) => `${kind} ${count}`).join(', ')}) ` +
1049
+ 'and this reader takes constraints from "constraints" alone, so it reads none of them and the rebuilt rig has none',
1050
+ );
1051
+ }
1052
+
1053
+ const renamed = arr(root.bones)
1054
+ .filter((bone) => isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone)
1055
+ .map((bone) => nameOf(bone));
1056
+ if (renamed.length > 0) {
1057
+ out.push(
1058
+ `${renamed.length} bone(s) carry "${LEGACY_BONE_INHERIT_KEY}" where 4.3 spells "inherit" (${spellSome(renamed)}), ` +
1059
+ 'each dropped as a field the rig spec has no home for — the BONE_FIELD line beside this one — so the ' +
1060
+ 'rebuilt bone inherits Normally',
1061
+ );
1062
+ }
1063
+
1064
+ const physics = [...arr(root.physics), ...arr(root.constraints).filter((one) => isObj(one) && one.type === 'physics')].filter(
1065
+ isObj,
1066
+ );
1067
+ const omitting = PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED.map(
1068
+ (field) => [field, physics.filter((one) => one[field] === undefined).length] as const,
1069
+ ).filter(([, count]) => count > 0);
1070
+ if (omitting.length > 0) {
1071
+ out.push(
1072
+ `${physics.length} physics constraint(s), of which ${omitting.map(([field, count]) => `${count} omit "${field}"`).join(' and ')} — ` +
1073
+ "JSON omits a field equal to the parser's default and that default is NOT the same number in 4.2 as in 4.3 " +
1074
+ '(#706 row 4), so the omission means one rig there and a different one here',
1075
+ );
1076
+ }
1077
+ return out;
1078
+ }
1079
+
1080
+ /**
1081
+ * The generation, read before a field of the file is.
1082
+ *
1083
+ * 🚨 **The silence this converts into a name was measured on the branch point.**
1084
+ * A 4.3 emit with its constraints moved into the top-level arrays 4.2 kept them
1085
+ * in came back through `ingest` as a rig spec with **zero** constraints and
1086
+ * **no finding about them at all**; the only blocker was `BONE_FIELD`, about the
1087
+ * bone key. A 3.8 label produced one `LOSS HEADER_REDERIVED` line and exit 0.
1088
+ * That is the shape this whole module exists to refuse — a decompiler that is
1089
+ * quiet about what it dropped.
1090
+ *
1091
+ * ⭐ **One code with the generation in the sentence, rather than one code per
1092
+ * generation.** `IG25` derives `docs/INGEST.md` §2.0's finding table by reading
1093
+ * every `note` call in this file for a code matching `[A-Z_]+`, and compares it
1094
+ * against rows matched with `[A-Z_<>]+`. A composed `GENERATION_${generation}`
1095
+ * would read `GENERATION_3.8` at runtime — digits and a dot — so the call would
1096
+ * be one the scan cannot resolve and the code would be missing from BOTH sides
1097
+ * of that comparison, which is the one failure comparing two sets cannot report.
1098
+ *
1099
+ * ⚠️ The same scan counts its own population with a second, dumber pattern over
1100
+ * the raw text, comments included — so a prose mention of that call spelled with
1101
+ * its opening bracket raises the count without raising the sites, and `IG25`
1102
+ * goes red on a file with nothing wrong in it. It did, on the first green run of
1103
+ * this change: **25 of 26 read**, the 26th being this very paragraph.
1104
+ *
1105
+ * ⚠️ It does NOT refuse the file. Everything here is a finding and both specs
1106
+ * are still written, for the reason `IngestFinding` states: a spec plus a list
1107
+ * of what is missing from it beats no spec. The exit code is the caller's and it
1108
+ * is 1, because this is a `blocker`.
1109
+ */
1110
+ function readGeneration(root: JsonObject, note: Note): void {
1111
+ const declared = obj(root.skeleton).spine;
1112
+ const generation = typeof declared === 'string' ? spineGeneration(declared) : null;
1113
+ if (generation !== null && generation === READER_GENERATION) return;
1114
+ const stated = typeof declared === 'string' ? `${JSON.stringify(declared)}` : 'no `skeleton.spine` at all';
1115
+ const reads = READER_GENERATION ?? '(none — this build\'s own version string is unreadable)';
1116
+ const losses = generationLosses(root);
1117
+ const measured =
1118
+ losses.length > 0
1119
+ ? `Measured on this file: ${losses.join('; ')}.`
1120
+ : 'Measured on this file: no constraint in a top-level array, no bone carrying ' +
1121
+ `"${LEGACY_BONE_INHERIT_KEY}", and no physics constraint omitting a default that moved — which is three ` +
1122
+ 'shapes counted and not a guarantee that nothing else differs.';
1123
+ if (generation === null) {
1124
+ note(
1125
+ 'blocker',
1126
+ 'GENERATION_UNKNOWN',
1127
+ 'skeleton.spine',
1128
+ `the file states ${stated} and no Spine generation matches it. A version is read as its LEADING major.minor ` +
1129
+ 'token — a down-export states "4.0-from-4.1.24", which is 4.0 data from a 4.1 editor — and the generations ' +
1130
+ `rigc knows are ${SPINE_GENERATIONS.join(', ')}; this reader reads ${reads}. It is NOT read as the nearest ` +
1131
+ 'one: a catalogue that handed 19 skeletons labelled "3.8.99" the nearest runtime it had loaded every one of ' +
1132
+ `them and posed 238 of 248 bones as NaN (issue #706 row 7). ${measured}`,
1133
+ );
1134
+ return;
1135
+ }
1136
+ note(
1137
+ 'blocker',
1138
+ 'GENERATION_UNSUPPORTED',
1139
+ 'skeleton.spine',
1140
+ `the file states ${stated}, which is Spine ${generation} data, and this reader reads Spine ${reads} only — it ` +
1141
+ `inverts a ${SPINE_VERSION} emitter. A generation mismatch does not throw; it drops what the newer format ` +
1142
+ `moved. ${measured} Reading the file with ${generation}'s OWN defaults is issue #706 item 2 — a ` +
1143
+ 'per-generation table extracted by machine from each runtime\'s `SkeletonJson` — and is not in this tool. ' +
1144
+ `Re-export from a ${reads} editor, or transcribe the file by hand (docs/INGEST.md §2).`,
1145
+ );
1146
+ }
1147
+
1148
+ /** The four fields a stage is, in the order the editor and `compile` write them. */
1149
+ const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
1150
+
1151
+ /**
1152
+ * Does this header declare a stage?
1153
+ *
1154
+ * 🔒 One reading, three callers below — the refusal, the origin default and the
1155
+ * early return — because they are three statements about the same header and two
1156
+ * spellings of "declares a stage" would be two things that have to agree. The
1157
+ * EXTENT is what declares one: an origin for a box that is not there is a shape
1158
+ * no export carries, which is how `diff`'s `stageFacts` and `compile`'s stage
1159
+ * guard already read it.
1160
+ */
1161
+ function declaresStage(head: JsonObject): boolean {
1162
+ return head.width !== undefined && head.height !== undefined;
1163
+ }
1164
+
1165
+ /**
1166
+ * A stage as one string, for a message that has to put two of them side by side.
1167
+ *
1168
+ * The origin is spelled `0` where it is absent, for the reason `HEADER_ORIGIN`
1169
+ * writes it: inside a declared extent that is what the omission means, so a
1170
+ * refusal that printed the file's box as `undefined,undefined,…` would be
1171
+ * quoting the file against the reading every other part of this tree holds.
1172
+ */
1173
+ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): string {
1174
+ return [x ?? 0, y ?? 0, width, height].map((value) => String(value)).join(',');
1175
+ }
1176
+
1177
+ /**
1178
+ * The rig spec's header fields a skeleton's own header can state — every one
1179
+ * but `stageBox`, which is the caller's reading of a slot (`--stage-box`,
1180
+ * issue #1168) and never a key of the Spine header: a header that carried a
1181
+ * key of that name would otherwise be copied into the rebuilt spec as a
1182
+ * request for a box nobody named.
1183
+ */
1184
+ const HEADER_FIELDS_FROM_FILE: readonly string[] = RIG_KEYS.RigSkeletonHeader.filter((field) => field !== 'stageBox');
1185
+
1186
+ /** The stage box `--stage-box` read (`readStageBox`): its slot, its attachment and the stage its four corners are. */
1187
+ interface StageBoxRead {
1188
+ slot: string;
1189
+ attachment: string;
1190
+ stage: IngestStage;
1191
+ }
1192
+
1193
+ /**
1194
+ * Read the stage from the bounding box in `slotName` (issue #1168,
1195
+ * `--stage-box`): the box `build` writes for a rig that asks for one
1196
+ * (`skeleton.stageBox`), read back as the rebuild's stage.
1197
+ *
1198
+ * 🔒 **Only a box `build` could write back is read, and everything else is
1199
+ * refused by name** — the slot is named by the caller, so a shape that is not
1200
+ * the stage is a mistake to report, not a guess to make. Refused: a slot the
1201
+ * skeleton does not have; a slot on a bone other than the root, or on a root
1202
+ * that states a setup transform (the box would not be the stage in world
1203
+ * space, and `build` refuses the same); a slot with no attachment in the
1204
+ * `default` skin, more than one, or one in another skin; an attachment that
1205
+ * is not a bounding box, states a `name` other than its placeholder, or is not
1206
+ * four unweighted vertices; four vertices that are not the corners of one
1207
+ * axis-aligned rectangle of positive size.
1208
+ *
1209
+ * The stage is the rectangle's least corner and its extent. What the rebuild
1210
+ * writes differently is one lossy finding, `STAGE_BOX_REWRITTEN`: `build`
1211
+ * writes the corners bottom-left first and counter-clockwise and states no
1212
+ * `color`, so a box in another order or with the editor's colour comes back as
1213
+ * the same rectangle, spelled `build`'s way.
1214
+ */
1215
+ function readStageBox(root: JsonObject, slotName: string, note: Note): StageBoxRead {
1216
+ const flag = `--stage-box ${slotName}`;
1217
+ const slot = arr(root.slots).map(obj).find((s) => s.name === slotName);
1218
+ if (slot === undefined) {
1219
+ throw new IngestError(`${flag}: the skeleton has no slot "${slotName}"; it declares [${arr(root.slots).map(nameOf).join(', ')}]`);
1220
+ }
1221
+ const bones = arr(root.bones).map(obj);
1222
+ const bone = bones.find((b) => b.name === slot.bone);
1223
+ if (bone === undefined || bone.parent !== undefined) {
1224
+ throw new IngestError(
1225
+ `${flag}: slot "${slotName}" hangs on bone "${String(slot.bone)}", which is not the root. A stage box is the stage's ` +
1226
+ "corners in world space, and build writes one only on the root — on any other bone the box's numbers are not the stage",
1227
+ );
1228
+ }
1229
+ const moved: string[] = [
1230
+ ...['x', 'y', 'rotation', 'shearX', 'shearY'].filter((key) => bone[key] !== undefined && bone[key] !== 0),
1231
+ ...['scaleX', 'scaleY'].filter((key) => bone[key] !== undefined && bone[key] !== 1),
1232
+ ];
1233
+ if (moved.length > 0) {
1234
+ throw new IngestError(
1235
+ `${flag}: slot "${slotName}" hangs on the root "${nameOf(bone)}", which states ${moved.map((key) => `${key} ${JSON.stringify(bone[key])}`).join(', ')}. ` +
1236
+ "A box on a root that moves at setup is not the stage in world space, and build refuses to write one there",
1237
+ );
1238
+ }
1239
+ const elsewhere = arr(root.skins).map(obj).filter((skin) => skin.name !== 'default' && Object.keys(obj(obj(skin.attachments)[slotName])).length > 0);
1240
+ if (elsewhere.length > 0) {
1241
+ throw new IngestError(
1242
+ `${flag}: slot "${slotName}" is also filled by skin(s) ${elsewhere.map((skin) => `"${nameOf(skin)}"`).join(', ')}. A stage box is its ` +
1243
+ "slot's one attachment, in the default skin, so a reader of that slot finds the stage and nothing else",
1244
+ );
1245
+ }
1246
+ const table = obj(obj(arr(root.skins).map(obj).find((skin) => skin.name === 'default')?.attachments)[slotName]);
1247
+ const placeholders = Object.keys(table);
1248
+ if (placeholders.length !== 1) {
1249
+ throw new IngestError(
1250
+ `${flag}: the default skin holds ${placeholders.length === 0 ? 'no attachment' : `${placeholders.length} attachments [${placeholders.join(', ')}]`} on slot "${slotName}"; ` +
1251
+ "a stage box is its slot's one attachment",
1252
+ );
1253
+ }
1254
+ const attachment = placeholders[0];
1255
+ const att = obj(table[attachment]);
1256
+ const where = `${flag}: attachment "${attachment}" on slot "${slotName}"`;
1257
+ if (att.type !== 'boundingbox') throw new IngestError(`${where} is a ${JSON.stringify(att.type ?? 'region')}, not a bounding box`);
1258
+ if (att.name !== undefined && att.name !== attachment) {
1259
+ throw new IngestError(`${where} states the name ${JSON.stringify(att.name)}; a stage box is named by its placeholder, which is what build writes`);
1260
+ }
1261
+ const vertices = Array.isArray(att.vertices) ? att.vertices : [];
1262
+ if (att.vertexCount !== 4 || vertices.length !== 8 || !vertices.every((v) => typeof v === 'number' && Number.isFinite(v))) {
1263
+ throw new IngestError(
1264
+ `${where} states vertexCount ${JSON.stringify(att.vertexCount)} and ${vertices.length} vertex number(s); the stage is four unweighted ` +
1265
+ 'corners — vertexCount 4 and eight finite numbers',
1266
+ );
1267
+ }
1268
+ const xy = vertices as number[];
1269
+ const xs = [...new Set([xy[0], xy[2], xy[4], xy[6]])].sort((a, b) => a - b);
1270
+ const ys = [...new Set([xy[1], xy[3], xy[5], xy[7]])].sort((a, b) => a - b);
1271
+ const corners = new Set([0, 2, 4, 6].map((i) => `${xy[i]},${xy[i + 1]}`));
1272
+ if (xs.length !== 2 || ys.length !== 2 || corners.size !== 4) {
1273
+ throw new IngestError(`${where} holds [${xy.join(', ')}], which is not the four corners of one axis-aligned rectangle of positive size`);
1274
+ }
1275
+ const stage: IngestStage = { x: xs[0], y: ys[0], width: xs[1] - xs[0], height: ys[1] - ys[0] };
1276
+ const rewritten: string[] = [];
1277
+ const order = [xs[0], ys[0], xs[1], ys[0], xs[1], ys[1], xs[0], ys[1]];
1278
+ if (order.some((v, i) => v !== xy[i])) rewritten.push(`its corners [${xy.join(', ')}] in build's order [${order.join(', ')}], bottom-left first and counter-clockwise`);
1279
+ if (att.color !== undefined) rewritten.push(`no color (the source states ${JSON.stringify(att.color)}, an editor affordance build does not write on a stage box)`);
1280
+ if (rewritten.length > 0) {
1281
+ note('lossy', 'STAGE_BOX_REWRITTEN', `skin "default" slot "${slotName}" attachment "${attachment}"`, `the rebuild writes the same rectangle with ${rewritten.join(', and ')}`);
1282
+ }
1283
+ return { slot: slotName, attachment, stage };
1284
+ }
1285
+
1286
+ /**
1287
+ * The rig spec's `skeleton` block — and the one judgement in this module.
1288
+ *
1289
+ * 🚨 **A skeleton JSON need not carry a box, and a stage cannot be derived.**
1290
+ * The stage is the working area the art was painted in; no pose of the rig
1291
+ * states it. So a file that declares none is written as declaring none
1292
+ * — `"width": null, "height": null`, the rig spec's spelling for that claim since
1293
+ * issue #578 — and `compile` then emits a header with none of the four fields,
1294
+ * which is the file that was read, byte for byte. No finding: nothing was lost,
1295
+ * nothing re-derived and nobody decided anything, and a line saying so would be
1296
+ * a finding about a file that rebuilds exactly (issue #714).
1297
+ *
1298
+ * ⚠️ **That is the shape of a whole production corpus, not a corner.** Until
1299
+ * #714 this branch was a `NO_STAGE` blocker and the only road through it was a
1300
+ * caller's `--stage` — a number the source never stated. Issue #714 counts 48 of
1301
+ * 48 production exports at 4.3.26 carrying no box; all twelve exports under
1302
+ * `examples/` carry all four fields, and take the declared branch below.
1303
+ *
1304
+ * 🔸 **Half a stage is still a blocker, and keeps the code.** A header that
1305
+ * states an origin with no extent, or one extent without the other, declares no
1306
+ * stage — the extent is what declares one — but it is not the absence either:
1307
+ * the rig spec holds a stage as four fields or none (`parseRigSpec` refuses the
1308
+ * pair `null` beside an `x`, and one extent alone is `compile`'s `no stage size`),
1309
+ * so the rebuild cannot carry what the file states. No export measured here has
1310
+ * that shape; the blocker names the fields it does state.
1311
+ *
1312
+ * ⭐ It is still the judgement that costs least to get wrong. `diff` does report
1313
+ * the header's box — `bounds_present` and `bounds_box` (issue #578, renamed by
1314
+ * #907) — but they sit in the `(reported)` block that no rung consults.
1315
+ *
1316
+ * 🔁 **What the box becomes (issue #907).** A header's box is its setup-pose
1317
+ * bounding box — what the format says the four are, and what every editor
1318
+ * export carries there. It is the only box a file has, so it becomes the
1319
+ * rebuild's stage (`A14` and `A19` measure against it, as they did against the
1320
+ * export's header). The rebuild's own header is not carried from it: `build`
1321
+ * computes the setup-pose bounding box of what it draws (`headerBoundsOf` in
1322
+ * `src/compile.ts`), which for a rebuild drawing the source's vertices is
1323
+ * spine-core's `getBounds` over the source on the header's 1e-6 grid at float32 — not the editor's
1324
+ * arithmetic, which on the twelve examples sits up to 0.0071 units away. A
1325
+ * rigc build's own header is likewise its bounding box, not its stage: the
1326
+ * stage is in `skeleton.model.json`, which the caller reads and hands in as
1327
+ * `documentStage` when that document digests this skeleton — and then it is
1328
+ * read in place of the header's box.
1329
+ *
1330
+ * 🔇 **Both of its silences were here, and both were around the DECLARED branch
1331
+ * rather than the missing one.** That branch used to be a bare early return, so
1332
+ * an origin the source omitted left no trace at all (issue #622) and a `--stage`
1333
+ * given beside a declared box was read after it and therefore never (issue #626).
1334
+ * Neither was wrong — the rebuild carried the right numbers both times — which is
1335
+ * exactly the shape this whole module exists to convert into something named:
1336
+ * a decompiler that is right for a reason it never states is a decompiler nobody
1337
+ * can check.
1338
+ */
1339
+ function ingestHeader(source: JsonObject, opts: IngestOptions, note: Note, box: StageBoxRead | null = null): JsonObject {
1340
+ // The stage box the caller named (issue #1168) is the file's own statement of its stage, so a second source beside it is refused, as `--stage` beside a declared box is.
1341
+ if (box !== null) {
1342
+ const spelled = spellStage(box.stage.x, box.stage.y, box.stage.width, box.stage.height);
1343
+ if (opts.stage !== undefined) {
1344
+ throw new IngestError(
1345
+ `--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}" and --stage supplied ` +
1346
+ `${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. Drop one of the two flags`,
1347
+ );
1348
+ }
1349
+ const doc = opts.documentStage;
1350
+ if (doc !== undefined && (doc === null || spellStage(doc.x, doc.y, doc.width, doc.height) !== spelled)) {
1351
+ throw new IngestError(
1352
+ `--stage-box ${box.slot} reads a stage of ${spelled} from the skeleton's bounding box "${box.attachment}", and the model document ` +
1353
+ `beside it states ${doc === null ? 'no stage' : spellStage(doc.x, doc.y, doc.width, doc.height)}: two statements of one stage that ` +
1354
+ "disagree, about one build. rigc will not choose between them — A50_STAGE_BOX_IS_THE_STAGE refuses such a build, so one of the files was edited after it",
1355
+ );
1356
+ }
1357
+ }
1358
+ // A rigc build's stage is its model document's (issue #907, `IngestOptions.documentStage`) or its stage box's (issue #1168): read in place of the header's box.
1359
+ const replaced = box !== null ? box.stage : opts.documentStage;
1360
+ const head: JsonObject = replaced === undefined ? source : Object.fromEntries(Object.entries(source).filter(([key]) => !(STAGE_FIELDS as readonly string[]).includes(key)));
1361
+ if (replaced) Object.assign(head, replaced);
1362
+ // 🚨 Before a line of transcription, because a header the caller contradicted
1363
+ // is not a header to start writing a spec from (issue #626).
1364
+ if (declaresStage(head) && opts.stage !== undefined) {
1365
+ throw new IngestError(
1366
+ `the skeleton declares a stage of ${spellStage(head.x, head.y, head.width, head.height)} and --stage supplied ` +
1367
+ `${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. ` +
1368
+ 'rigc will not overwrite a box the file states — the file is the record of what was measured, and the flag ' +
1369
+ 'is for a skeleton that declares none. Drop --stage, or correct `skeleton` in the source if its box is wrong',
1370
+ );
1371
+ }
1372
+ const out: JsonObject = {};
1373
+ for (const field of HEADER_FIELDS_FROM_FILE) if (head[field] !== undefined) out[field] = head[field];
1374
+ // The stage box is the caller's reading of a slot, never a header key: a Spine header has no such field (issue #1168).
1375
+ if (box !== null) out.stageBox = { slot: box.slot, attachment: box.attachment };
1376
+ for (const key of Object.keys(head)) {
1377
+ if (HEADER_FIELDS_FROM_FILE.includes(key)) continue;
1378
+ if (HEADER_REDERIVED.includes(key)) {
1379
+ // Two-sided on purpose: the same fact reads as bookkeeping when the two
1380
+ // agree and as a warning when they do not, and a reader needs to be told
1381
+ // which — a rebuild of a 4.2 export states 4.3, in one field, silently.
1382
+ const same = head[key] === SPINE_VERSION;
1383
+ note(
1384
+ 'lossy',
1385
+ 'HEADER_REDERIVED',
1386
+ `skeleton.${key}`,
1387
+ `the source states ${JSON.stringify(head[key])} and the rig spec has no field for it: a rebuild writes the ` +
1388
+ `version of the runtime rigc links, ${SPINE_VERSION}` +
1389
+ (same ? ', which is the same string, so nothing moves' : ' — so this field WILL change on the rebuild'),
1390
+ );
1391
+ continue;
1392
+ }
1393
+ note(
1394
+ 'lossy',
1395
+ 'HEADER_BOOKKEEPING',
1396
+ `skeleton.${key}`,
1397
+ `the editor writes "${key}" and the rig spec has no field for it; it is dropped and nothing reads it back`,
1398
+ );
1399
+ }
1400
+ if (declaresStage(head)) {
1401
+ // ⭐ **Inside a declared extent, an omitted origin IS `0`** (issue #620),
1402
+ // which is a reading of the format rather than a value invented for a gap:
1403
+ // `compile` assembles `header.x = rig.skeleton?.x ?? 0` under this same
1404
+ // guard, `diff`'s `stageFacts` reads the omission the same way, and
1405
+ // `stageFacts`'s own comment carries the four measurements behind it. So the
1406
+ // spec states what the file meant instead of leaving the rebuild to a
1407
+ // default in another module — and the half that has to be said out loud is
1408
+ // the other one: the rebuilt header SPELLS a field the source omitted
1409
+ // (issue #622).
1410
+ const omitted = ['x', 'y'].filter((field) => head[field] === undefined);
1411
+ if (omitted.length > 0) {
1412
+ out.x = head.x ?? 0;
1413
+ out.y = head.y ?? 0;
1414
+ note(
1415
+ 'lossy',
1416
+ 'HEADER_ORIGIN',
1417
+ `skeleton.${omitted.join('/')}`,
1418
+ `the source declares a ${String(head.width)}x${String(head.height)} stage and omits ` +
1419
+ `${omitted.map((field) => `"${field}"`).join(' and ')}; inside a declared extent an omitted origin is 0, ` +
1420
+ 'which is how `compile` and `diff` read it (#620), so the rig spec states x=' +
1421
+ `${String(out.x)}, y=${String(out.y)} rather than leaving the rebuild to a default in another module. ` +
1422
+ `⚠️ The rebuilt header WILL spell ${omitted.length > 1 ? 'those fields' : 'that field'}: it carries the ` +
1423
+ 'setup-pose bounding box `build` computes (#907), whose origin is wherever the art sits',
1424
+ );
1425
+ }
1426
+ return out;
1427
+ }
1428
+ if (opts.stage === undefined) {
1429
+ const stated = STAGE_FIELDS.filter((field) => head[field] !== undefined);
1430
+ if (stated.length === 0) {
1431
+ // The absence, carried: the pair goes where `RIG_KEYS` orders it, so the
1432
+ // spec reads like one a transcriber would have written by hand.
1433
+ const carried: JsonObject = {};
1434
+ for (const field of HEADER_FIELDS_FROM_FILE) {
1435
+ if (field === 'width' || field === 'height') carried[field] = null;
1436
+ else if (out[field] !== undefined) carried[field] = out[field];
1437
+ }
1438
+ return carried;
1439
+ }
1440
+ const unstated = STAGE_FIELDS.filter((field) => head[field] === undefined);
1441
+ note(
1442
+ 'blocker',
1443
+ 'NO_STAGE',
1444
+ 'skeleton.width/height',
1445
+ `the skeleton states ${stated.map((field) => `"${field}"`).join(', ')} and no ` +
1446
+ `${unstated.map((field) => `"${field}"`).join(', ')}, so it declares no stage — a width and a height are ` +
1447
+ 'what declare one — and it is not the absence either. A rig spec holds a stage as four fields or none, so ' +
1448
+ 'the rebuild cannot carry what this header states. Give --stage x,y,w,h if the box is known — the value is ' +
1449
+ 'the caller\'s, not derived: posing the rig would give the ANIMATED extent, which is a different number from ' +
1450
+ 'the setup box — or take the stray field(s) out of the source, and the absence is then carried as it stands',
1451
+ );
1452
+ return out;
1453
+ }
1454
+ Object.assign(out, opts.stage);
1455
+ note(
1456
+ 'judgement',
1457
+ 'NO_STAGE',
1458
+ 'skeleton.width/height',
1459
+ `the skeleton declares no stage and the caller supplied ${opts.stage.x},${opts.stage.y},${opts.stage.width},` +
1460
+ `${opts.stage.height}. Nothing measured it against the art: \`A14\` and \`A19\` measure the art against it ` +
1461
+ 'and `diff` reports it, so a wrong box is green everywhere. Without --stage the absence is carried instead, ' +
1462
+ 'and the rebuild declares no stage either',
1463
+ );
1464
+ return out;
1465
+ }
1466
+
1467
+ /**
1468
+ * One attachment, by type. Inverts `buildRigAttachment`'s five branches.
1469
+ *
1470
+ * The types rigc does not emit are refused BY NAME rather than dropped, which is
1471
+ * the same reason `buildRigAttachment` refuses them: the parser's own behaviour
1472
+ * on a type it does not know is to return null and carry on, so a decompiler
1473
+ * that skipped one would hand back a spec that is quietly missing an attachment.
1474
+ */
1475
+ function ingestAttachment(
1476
+ att: JsonObject,
1477
+ placeholder: string,
1478
+ boneNames: readonly string[],
1479
+ opts: IngestOptions,
1480
+ note: Note,
1481
+ at: { where: string },
1482
+ ): JsonObject {
1483
+ // `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
1484
+ // does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
1485
+ const type = att.type === undefined ? 'region' : String(att.type);
1486
+ const out: JsonObject = {};
1487
+
1488
+ // A LINKED mesh, in either of the format's two spellings. The format decides
1489
+ // it — one branch of the reader for `mesh` and `linkedmesh`, and a truthy
1490
+ // `source` decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1491
+ // `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
1492
+ // is read as an ordinary mesh, whose `uvs` it does not have, and the parser
1493
+ // throws on it. Reading the second as a link would be this module inventing a
1494
+ // construct the file does not contain (issue #691).
1495
+ const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
1496
+
1497
+ // The three types the parser gives a texture `path` to — and reads a
1498
+ // `sequence` on. `readAttachment` reaches `getValue(map, "path", name)` in the
1499
+ // `region` branch (`SkeletonJson.js:529`) and in the shared `mesh`/`linkedmesh`
1500
+ // branch (`:560`); `boundingbox`, `path`, `point` and `clipping` are
1501
+ // constructed from the name alone and resolve no region at all.
1502
+ const resolvesRegion = linked || type === 'region' || type === 'mesh';
1503
+
1504
+ /** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
1505
+ const carryArt = (): void => {
1506
+ if (att.path !== undefined) out.path = att.path;
1507
+ // `buildRigRegion` writes `path` when the image basename differs from the
1508
+ // name the attachment carries — its stated `name`, else the placeholder —
1509
+ // so naming the image after the region this attachment RESOLVES
1510
+ // (`path ?? name ?? placeholder`, the defaults the format's reader applies
1511
+ // at `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1512
+ // decision AND the same atlas region name.
1513
+ if (opts.art === 'loose') {
1514
+ const region = att.path ?? (typeof att.name === 'string' ? att.name : placeholder);
1515
+ out.image = `${String(region)}.png`;
1516
+ }
1517
+ if (att.width !== undefined) out.width = att.width;
1518
+ if (att.height !== undefined) out.height = att.height;
1519
+ };
1520
+
1521
+ /**
1522
+ * The vertex array, as one of the two encodings `readVertices` decides between.
1523
+ *
1524
+ * Inverts `buildVertexGeometry` / `encodeNamedWeights`: the run is unweighted
1525
+ * when it is exactly as long as the coordinate count the attachment declares,
1526
+ * and a weight run otherwise. That length comparison is the parser's own.
1527
+ */
1528
+ const geometry = (declaredPairs: number | undefined): void => {
1529
+ const vertices = numbers(att.vertices);
1530
+ if (vertices === undefined) return;
1531
+ if (declaredPairs !== undefined && vertices.length === declaredPairs * 2) out.vertices = vertices;
1532
+ else out.weights = decodeWeights(vertices, boneNames);
1533
+ };
1534
+
1535
+ const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
1536
+
1537
+ if (linked) {
1538
+ out.type = 'linkedmesh';
1539
+ carryArt();
1540
+ out.source = att.source;
1541
+ // Only what the file states. `slot`, `skin` and `timelines` each have a
1542
+ // parser default (this attachment's slot, the default skin, true), and
1543
+ // writing one the source omitted would be a rebuild that says more than the
1544
+ // file did — and `buildRigLinkedMesh` drops it again on the way back out.
1545
+ for (const field of ['slot', 'skin', 'timelines', 'color']) if (att[field] !== undefined) out[field] = att[field];
1546
+ const dropped = LINKED_MESH_UNREAD_KEYS.filter((field) => att[field] !== undefined);
1547
+ if (dropped.length > 0) {
1548
+ note(
1549
+ 'lossy',
1550
+ 'ATTACHMENT_LINK_GEOMETRY',
1551
+ at.where,
1552
+ `the attachment is a LINKED mesh and states ${dropped.map((field) => `\`${field}\``).join(', ')}, which the ` +
1553
+ 'parser reads with nothing at all: it returns from the `source` branch before `readVertices` ' +
1554
+ `(\`SkeletonJson.ts:582-586\`), so what this attachment draws is the geometry of ${JSON.stringify(att.source)}. ` +
1555
+ `The rebuild drops ${dropped.length === 1 ? 'it' : 'them'} — the rig spec refuses geometry on a link by name, ` +
1556
+ 'and carrying it would write a spec `build` will not take. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` ' +
1557
+ 'is the same fact held against the source file',
1558
+ );
1559
+ }
1560
+ } else if (type === 'region') {
1561
+ carryArt();
1562
+ for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
1563
+ if (att[field] !== undefined) out[field] = att[field];
1564
+ }
1565
+ } else if (type === 'mesh') {
1566
+ out.type = 'mesh';
1567
+ carryArt();
1568
+ out.uvs = att.uvs;
1569
+ out.triangles = att.triangles;
1570
+ geometry(Array.isArray(att.uvs) ? att.uvs.length / 2 : undefined);
1571
+ // Carried rather than re-derived: `authoredHullAndEdges` takes an authored
1572
+ // pair as written and cross-checks `hull` against the triangles, so stating
1573
+ // both keeps the emitted arrays identical instead of equal-by-derivation.
1574
+ if (att.hull !== undefined) out.hull = att.hull;
1575
+ if (att.edges !== undefined) out.edges = att.edges;
1576
+ if (att.color !== undefined) out.color = att.color;
1577
+ } else if (type === 'boundingbox' || type === 'clipping') {
1578
+ out.type = type;
1579
+ out.vertexCount = att.vertexCount;
1580
+ geometry(vertexCount);
1581
+ if (att.color !== undefined) out.color = att.color;
1582
+ if (type === 'clipping') {
1583
+ for (const field of ['end', 'convex', 'inverse']) if (att[field] !== undefined) out[field] = att[field];
1584
+ }
1585
+ } else if (type === 'path') {
1586
+ out.type = 'path';
1587
+ out.vertexCount = att.vertexCount;
1588
+ geometry(vertexCount);
1589
+ for (const field of ['closed', 'constantSpeed', 'color']) if (att[field] !== undefined) out[field] = att[field];
1590
+ // Carried verbatim (issue #804), where until then it was dropped as `LOSS
1591
+ // PATH_LENGTHS` and re-measured. The editor measures it on the pose the
1592
+ // first update gives the path constraint — constraints applied — and rigc
1593
+ // does not pose, so the re-measure was a different number on every path a
1594
+ // constraint moves at rest, and on every weighted path over a scaled bone.
1595
+ if (att.lengths !== undefined) out.lengths = att.lengths;
1596
+ } else {
1597
+ note(
1598
+ 'blocker',
1599
+ `ATTACHMENT_${type.toUpperCase()}`,
1600
+ at.where,
1601
+ `attachment type ${JSON.stringify(type)} is in the Spine 4.3 format and rigc does not emit it (it emits ` +
1602
+ `${ATTACHMENT_TYPES.join(', ')}; point is the one deferred type left — docs/SPEC_COVERAGE.md part 1-6 says ` +
1603
+ 'what it would carry). The rebuild will not have this attachment',
1604
+ );
1605
+ return out;
1606
+ }
1607
+
1608
+ // A numbered image series (issue #729). Carried as the file states it —
1609
+ // the four fields `readSequence` reads — wherever the rig spec can say it;
1610
+ // what is left for the blocker is a block the parser reads into a series
1611
+ // that is not the one written, or one on a kind the parser never reads it on.
1612
+ // `null` is the parser's own absent (`getValue(map, "sequence", null)`), so it
1613
+ // is read as no series rather than as a malformed one.
1614
+ if (att.sequence !== undefined && att.sequence !== null) {
1615
+ const refusal = resolvesRegion
1616
+ ? sequenceBlockRefusal(att.sequence)
1617
+ : `the attachment is a ${type}, and \`readAttachment\` reads a \`sequence\` only on a region or a mesh ` +
1618
+ '(`SkeletonJson.js:530`, `:561`) — the parser never read this one, and the rig spec refuses it there by name';
1619
+ if (refusal === null) {
1620
+ const seq = att.sequence as JsonObject;
1621
+ const carried: JsonObject = {};
1622
+ for (const field of ['count', 'start', 'digits', 'setup']) if (seq[field] !== undefined) carried[field] = seq[field];
1623
+ out.sequence = carried;
1624
+ // The frames ARE the art: `<path><number>` regions, which on the loose
1625
+ // route are PNGs of those names. A single `image` would name one region
1626
+ // the series does not have, and the rig spec refuses the pair.
1627
+ delete out.image;
1628
+ } else {
1629
+ note(
1630
+ 'blocker',
1631
+ 'ATTACHMENT_SEQUENCE',
1632
+ at.where,
1633
+ `the attachment's \`sequence\` block is ${JSON.stringify(att.sequence)}: ${refusal}. The rebuild draws the ` +
1634
+ 'single region this attachment names instead of a series',
1635
+ );
1636
+ }
1637
+ }
1638
+ // 🔑 The attachment's own `name`, carried VERBATIM wherever the source states
1639
+ // one — and nowhere else (issue #796). It is the runtime's `Attachment.name`
1640
+ // (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`) and, with no
1641
+ // `path`, the region the attachment draws; the rig spec has a field for it
1642
+ // and `compile` writes exactly what that field states, so nothing about it is
1643
+ // lost and nothing is re-derived. A name equal to its placeholder is carried
1644
+ // too: the source spelled it, and the rebuild is held to the source's text.
1645
+ // Put right after `type`, the order `RIG_KEYS` gives the field.
1646
+ if (att.name === undefined) return out;
1647
+ const { type: carriedType, ...rest } = out;
1648
+ return carriedType === undefined ? { name: att.name, ...rest } : { type: carriedType, name: att.name, ...rest };
1649
+ }
1650
+
1651
+ /**
1652
+ * Why a `sequence` block cannot be carried as written, or `null` when it can —
1653
+ * the rig spec's own refusals (`checkRigSequence`), stated about a file.
1654
+ *
1655
+ * Every one of them is a series the parser loads into something other than
1656
+ * what the file says (issue #729): no `count` is 0 regions, a `setup` past the
1657
+ * end is clamped, a fraction names a region like `stem1.5`.
1658
+ */
1659
+ function sequenceBlockRefusal(seq: unknown): string | null {
1660
+ if (typeof seq !== 'object' || seq === null || Array.isArray(seq)) {
1661
+ return 'a sequence is an object of `count`, `start`, `digits` and `setup`';
1662
+ }
1663
+ const block = seq as JsonObject;
1664
+ if (block.count === undefined) {
1665
+ return 'it states no `count`, and `readSequence` reads 0 — the attachment loads holding no region and draws nothing';
1666
+ }
1667
+ for (const [field, min] of [['count', 1], ['start', 0], ['digits', 0], ['setup', 0]] as const) {
1668
+ const value = block[field];
1669
+ if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < min)) {
1670
+ return `\`${field}\` is ${JSON.stringify(value)}, and the rig spec takes a whole number of at least ${min} there`;
1671
+ }
1672
+ }
1673
+ if (typeof block.setup === 'number' && block.setup >= (block.count as number)) {
1674
+ return `\`setup\` ${block.setup} is past the end of a ${String(block.count)}-frame series, and \`Sequence.resolveIndex\` clamps it to the last frame`;
1675
+ }
1676
+ return null;
1677
+ }
1678
+
1679
+ /** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
1680
+ function ingestAnimation(
1681
+ animName: string,
1682
+ anim: JsonObject,
1683
+ root: JsonObject,
1684
+ note: Note,
1685
+ inert: ReadonlyMap<string, unknown>,
1686
+ omitted: (constraint: string, property: string, lastKey: number) => void,
1687
+ ): JsonObject {
1688
+ const tracks: JsonObject[] = [];
1689
+ let maxT = 0;
1690
+ const seeT = (t: number): void => {
1691
+ if (t > maxT) maxT = t;
1692
+ };
1693
+ const timeOf = (key: JsonObject): number => {
1694
+ const t = typeof key.time === 'number' ? key.time : 0;
1695
+ seeT(t);
1696
+ return t;
1697
+ };
1698
+
1699
+ /**
1700
+ * A key's easing, as the motion spec spells it.
1701
+ *
1702
+ * Inverts `rawCurve`: `"stepped"` is a named easing the compiler passes
1703
+ * through, and an array is the absolute (time, value) control points, four per
1704
+ * channel, which `curve` takes verbatim. A key with neither is linear.
1705
+ */
1706
+ const easing = (key: JsonObject, out: JsonObject): void => {
1707
+ if (key.curve === 'stepped') out.ease = 'stepped';
1708
+ else if (key.curve !== undefined) out.curve = key.curve;
1709
+ };
1710
+
1711
+ /**
1712
+ * A value track of any of the four families. Inverts `compileValueTrack`.
1713
+ *
1714
+ * ⚠️ An EDITOR omits a channel that equals the parser's default, and the
1715
+ * spec's `v` is positional — so an omission is filled at that channel's
1716
+ * default, which is the value the runtime reads there. The emitter leaves
1717
+ * the same channel out again wherever `PARSER_DEFAULTS` has a row for the
1718
+ * key's kind (issue #716), so the rebuild is the source's own text there and
1719
+ * nothing is said. What is reported, once per track, is a filled channel the
1720
+ * emitter WILL write — a kind with no measured row — because that one is a
1721
+ * restatement rather than a copy and a reader should know which.
1722
+ */
1723
+ const valueTrack = (target: JsonObject, property: string, keys: readonly unknown[], shape: TrackShape, where: string): void => {
1724
+ const fields = shape.map(([field]) => field);
1725
+ const out: JsonObject[] = [];
1726
+ let restated = 0;
1727
+ const family = Object.keys(target)[0];
1728
+ const row = PARSER_DEFAULTS[`${family} ${property} key`];
1729
+ for (const raw of keys) {
1730
+ const key = obj(raw);
1731
+ const entry: JsonObject = { t: timeOf(key) };
1732
+ const filled: string[] = [];
1733
+ // The zero-field branch: `reset` IS the event, so the key carries no value
1734
+ // and the spec spells that `null`.
1735
+ entry.v =
1736
+ shape.length === 0
1737
+ ? null
1738
+ : shape.map(([field, dflt]) => {
1739
+ if (key[field] !== undefined) return key[field];
1740
+ filled.push(field);
1741
+ // A string default names another field of THIS key (`mixY` ->
1742
+ // `mixX`); when that one is absent too the chain ends at 1, which
1743
+ // is what `ConstraintChannel.dflt` holds for both.
1744
+ if (typeof dflt !== 'string') return dflt;
1745
+ return key[dflt] === undefined ? 1 : key[dflt];
1746
+ });
1747
+ if (filled.length > 0) {
1748
+ // The key as the emitter will hold it — every channel stated — asked
1749
+ // the one question the emitter asks of it.
1750
+ const emitted: JsonObject = { ...key };
1751
+ shape.forEach(([field], i) => (emitted[field] = (entry.v as JsonObject[string][])[i]));
1752
+ const site = { object: emitted, previous: () => null };
1753
+ restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
1754
+ }
1755
+ easing(key, entry);
1756
+ for (const field of Object.keys(key)) {
1757
+ if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
1758
+ note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
1759
+ }
1760
+ out.push(entry);
1761
+ }
1762
+ if (restated > 0) {
1763
+ note(
1764
+ 'lossy',
1765
+ 'TIMELINE_KEY_RESTATED',
1766
+ where,
1767
+ `${restated} channel value(s) the source omits are written out at the parser's default (${shape
1768
+ .map(([field, dflt]) => `${field}=${String(dflt)}`)
1769
+ .join(', ')}), because the motion spec's \`v\` is positional and the emitter has no measured row ` +
1770
+ `for "${family} ${property}" keys to leave them out by — the same values the runtime reads`,
1771
+ );
1772
+ }
1773
+ tracks.push({ ...target, property, keys: out });
1774
+ };
1775
+
1776
+ /**
1777
+ * One family of constraint timelines: `<family>.<constraint>.<timeline>`.
1778
+ *
1779
+ * ⭐ All three tables are now COMPLETE against `SkeletonJson`'s switch for
1780
+ * their group, so the blocker below no longer fires on anything the runtime
1781
+ * plays — it is reachable only for a timeline name the parser itself falls
1782
+ * through (`:1094` for physics, and neither the path nor the slider switch
1783
+ * has a default either). It is kept rather than deleted because `ingest`'s
1784
+ * contract is byte identity and not equivalence: a name nothing reads is
1785
+ * still a name the rebuild does not write. The alternative — demoting it to
1786
+ * `lossy`, on the argument that the rebuilt skeleton plays identically — is
1787
+ * a decision about all three families and is not made here.
1788
+ */
1789
+ /**
1790
+ * The physics constraints the rebuild carries — every one the file declares
1791
+ * but those `ingest` omits as inert — which is what an unnamed physics
1792
+ * timeline can reach in it.
1793
+ */
1794
+ const carriedPhysics = arr(root.constraints)
1795
+ .map(obj)
1796
+ .filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
1797
+ /** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
1798
+ const unreached: Array<{ property: string; lastKey: number }> = [];
1799
+ const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
1800
+ for (const [name, timelines] of objEntries(anim[group])) {
1801
+ for (const [property, keys] of arrEntries(timelines)) {
1802
+ // A timeline keyed to a physics constraint `ingest` omitted (issue #731)
1803
+ // goes with it: carried, it names a constraint the rebuilt rig has not
1804
+ // got, and `build` refuses the whole motion spec over it. Its keys are
1805
+ // not seen by `seeT` — they are not in the rebuild — and their latest
1806
+ // time is handed back so the finding can say when that shortened one.
1807
+ if (group === 'physics' && inert.has(name)) {
1808
+ let lastKey = 0;
1809
+ for (const raw of keys) {
1810
+ const t = obj(raw).time;
1811
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1812
+ }
1813
+ omitted(name, property, lastKey);
1814
+ continue;
1815
+ }
1816
+ const shape = shapes[property];
1817
+ const where = `animation "${animName}" ${group} "${name}" ${property}`;
1818
+ if (shape === undefined) {
1819
+ note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
1820
+ continue;
1821
+ }
1822
+ // The empty name is the physics group's timeline that names no
1823
+ // constraint (issue #726), which the motion spec spells `"*"`. It
1824
+ // writes every carried constraint declaring the property global —
1825
+ // `reset` every one — and one that reaches none is a no-op the rebuild
1826
+ // would be refused over by name, so it goes, and is said, instead.
1827
+ if (group === 'physics' && name === '') {
1828
+ const reaches =
1829
+ property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
1830
+ if (!reaches) {
1831
+ let lastKey = 0;
1832
+ for (const raw of keys) {
1833
+ const t = obj(raw).time;
1834
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1835
+ }
1836
+ unreached.push({ property, lastKey });
1837
+ continue;
1838
+ }
1839
+ valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
1840
+ continue;
1841
+ }
1842
+ valueTrack({ [group]: name }, property, keys, shape, where);
1843
+ }
1844
+ }
1845
+ };
1846
+
1847
+ for (const [bone, timelines] of objEntries(anim.bones)) {
1848
+ for (const [property, keys] of arrEntries(timelines)) {
1849
+ const shape = BONE_TRACKS[property];
1850
+ const where = `animation "${animName}" bone "${bone}" ${property}`;
1851
+ if (property === INHERIT_TRACK.property) {
1852
+ // `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
1853
+ // table's spelling, which is what `build` writes back; a key that omits
1854
+ // it, or spells it with a capital the runtime also folds, is a key the
1855
+ // rebuild states differently and is counted as such. A spelling the
1856
+ // runtime cannot resolve at all is carried as written — the file plays
1857
+ // no mode there, and `build` refuses it by name rather than guessing one.
1858
+ let restated = 0;
1859
+ const out = keys.map((raw) => {
1860
+ const key = obj(raw);
1861
+ const entry: JsonObject = { t: timeOf(key) };
1862
+ const written = key[INHERIT_TRACK.field];
1863
+ const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
1864
+ if (mode !== written && mode !== undefined) restated++;
1865
+ entry.v = mode ?? (written as JsonObject[string]);
1866
+ for (const field of Object.keys(key)) {
1867
+ if (field === 'time' || field === INHERIT_TRACK.field) continue;
1868
+ note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
1869
+ }
1870
+ return entry;
1871
+ });
1872
+ if (restated > 0) {
1873
+ note(
1874
+ 'lossy',
1875
+ 'TIMELINE_KEY_RESTATED',
1876
+ where,
1877
+ `${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
1878
+ `mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
1879
+ 'spelling the editor writes',
1880
+ );
1881
+ }
1882
+ tracks.push({ bone, property, keys: out });
1883
+ continue;
1884
+ }
1885
+ if (shape === undefined) {
1886
+ note(
1887
+ 'blocker',
1888
+ 'BONE_TIMELINE',
1889
+ where,
1890
+ `timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
1891
+ 'and nothing else, so the rebuild plays nothing here',
1892
+ );
1893
+ continue;
1894
+ }
1895
+ valueTrack({ bone }, property, keys, shape, where);
1896
+ }
1897
+ }
1898
+
1899
+ for (const [slot, timelines] of objEntries(anim.slots)) {
1900
+ for (const [property, keys] of arrEntries(timelines)) {
1901
+ const where = `animation "${animName}" slot "${slot}" ${property}`;
1902
+ if (property === 'attachment') {
1903
+ // Inverts `compileTrack`'s attachment branch: `{time, name}`, where a
1904
+ // null name is "show nothing". Attachment keys are stepped by nature and
1905
+ // carry no curve at all.
1906
+ tracks.push({
1907
+ slot,
1908
+ property: 'attachment',
1909
+ keys: keys.map((raw) => {
1910
+ const key = obj(raw);
1911
+ return { t: timeOf(key), v: key.name === undefined ? null : key.name };
1912
+ }),
1913
+ });
1914
+ } else if (property === 'rgba') {
1915
+ // Inverts `compileTrack`'s rgba branch, whose key is `{time, color}`.
1916
+ tracks.push({
1917
+ slot,
1918
+ property: 'rgba',
1919
+ keys: keys.map((raw) => {
1920
+ const key = obj(raw);
1921
+ const entry: JsonObject = { t: timeOf(key), v: hexToRgba(String(key.color)) };
1922
+ easing(key, entry);
1923
+ return entry;
1924
+ }),
1925
+ });
1926
+ } else if (property === 'rgba2') {
1927
+ // Inverts `compileTrack`'s rgba2 branch, whose key is `{time, light,
1928
+ // dark}`. The spec's `v` concatenates the two in the format's own
1929
+ // channel order — light r g b a, then dark r g b — which is the order
1930
+ // `readCurve` indexes a curve array by, so a key and its curve stay
1931
+ // parallel through the round trip.
1932
+ tracks.push({
1933
+ slot,
1934
+ property: 'rgba2',
1935
+ keys: keys.map((raw) => {
1936
+ const key = obj(raw);
1937
+ const entry: JsonObject = {
1938
+ t: timeOf(key),
1939
+ v: [...hexToRgba(String(key.light)), ...hexToRgb(String(key.dark))],
1940
+ };
1941
+ easing(key, entry);
1942
+ return entry;
1943
+ }),
1944
+ });
1945
+ } else if (property === 'alpha') {
1946
+ // Inverts `compileTrack`'s alpha branch, whose key is `{time, value}` —
1947
+ // the one colour shape the format stores as a number, read by
1948
+ // `readTimeline1` with a per-key default of **0**. That is a value
1949
+ // track's shape exactly, so it goes through the same inversion and
1950
+ // inherits its two findings: an omitted `value` is written out at 0 and
1951
+ // reported as `TIMELINE_KEY_RESTATED`, and a field the shape has no
1952
+ // place for is a `TIMELINE_FIELD` blocker (issue #730).
1953
+ valueTrack({ slot }, property, keys, [['value', 0]], where);
1954
+ } else if (property === 'rgb' || property === 'rgb2') {
1955
+ // Inverts `compileTrack`'s other two separable shapes (issue #730):
1956
+ // `rgb` is `{time, color: "rrggbb"}` and `rgb2` is `{time, light:
1957
+ // "rrggbb", dark: "rrggbb"}`, and the spec's `v` is their channels in
1958
+ // `readCurve`'s order. Each keeps its own name and its own key times:
1959
+ // folding an `rgb` and an `alpha` into one `rgba` would state each
1960
+ // channel at the other's key times, a value nobody keyed.
1961
+ tracks.push({
1962
+ slot,
1963
+ property,
1964
+ keys: keys.map((raw) => {
1965
+ const key = obj(raw);
1966
+ const v =
1967
+ property === 'rgb'
1968
+ ? hexToRgb(String(key.color))
1969
+ : [...hexToRgb(String(key.light)), ...hexToRgb(String(key.dark))];
1970
+ const entry: JsonObject = { t: timeOf(key), v };
1971
+ easing(key, entry);
1972
+ return entry;
1973
+ }),
1974
+ });
1975
+ } else {
1976
+ // 🔒 Composed from both tables, so it says the true thing whichever of
1977
+ // two states it is reached in. A name the FORMAT has and the spec does
1978
+ // not is the first; since issue #730 carried the last three there is no
1979
+ // such name (`UNSPELT_SLOT_TRACKS` is empty), and what still reaches
1980
+ // here is a name the format does not have at all — which
1981
+ // `SkeletonJson.readAnimation` throws on (`Invalid timeline type for a
1982
+ // slot`), so the sentence says so instead of printing an empty
1983
+ // "remaining" list about a file no runtime loads.
1984
+ const inFormat = property in CHANNELS_BY_KIND.slot;
1985
+ note(
1986
+ 'blocker',
1987
+ 'SLOT_TIMELINE',
1988
+ where,
1989
+ (inFormat
1990
+ ? `timeline "${property}" is in the format and the motion spec has no track for it`
1991
+ : `timeline "${property}" is not a slot timeline the format has — the runtime's reader throws ` +
1992
+ '"Invalid timeline type for a slot" on it, so no player loads this file') +
1993
+ ` — a slot track is ${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here` +
1994
+ (UNSPELT_SLOT_TRACKS.length > 0
1995
+ ? `. The format's remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`
1996
+ : ''),
1997
+ );
1998
+ }
1999
+ }
2000
+ }
2001
+
2002
+ family('path', PATH_TRACKS);
2003
+ family('physics', PHYSICS_TRACKS);
2004
+ family('slider', SLIDER_TRACKS);
2005
+
2006
+ const ik = constraintGroup('ik', animName, anim, root, IK_KEY_DEFAULTS, seeT, note);
2007
+ const transform = constraintGroup('transform', animName, anim, root, TRANSFORM_KEY_DEFAULTS, seeT, note);
2008
+
2009
+ // deform — `attachments.<skin>.<slot>.<attachment>.<timeline>`.
2010
+ // Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
2011
+ // and whose `offset` is omitted at 0 (the parser's default).
2012
+ const deform: JsonObject[] = [];
2013
+ // sequence — the other attachment timeline, inverting `compileSequenceTrack`:
2014
+ // `{time, mode?, index?, delay?}` with each field written only where the file
2015
+ // wrote it, because each has a parser default (`"hold"`, 0, the previous
2016
+ // key's delay) and restating one would be a rebuild saying more than the file.
2017
+ const sequence: JsonObject[] = [];
2018
+ for (const [skinName, perSkin] of objEntries(anim.attachments)) {
2019
+ for (const [slot, perSlot] of objEntries(perSkin)) {
2020
+ for (const [attachment, timelines] of objEntries(perSlot)) {
2021
+ for (const [property, keys] of arrEntries(timelines)) {
2022
+ const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
2023
+ if (property === 'sequence') {
2024
+ const entry: JsonObject = {
2025
+ slot,
2026
+ attachment,
2027
+ keys: keys.map((raw) => {
2028
+ const key = obj(raw);
2029
+ const out: JsonObject = { t: timeOf(key) };
2030
+ for (const field of ['mode', 'index', 'delay']) if (key[field] !== undefined) out[field] = key[field];
2031
+ return out;
2032
+ }),
2033
+ };
2034
+ if (skinName !== 'default') entry.skin = skinName;
2035
+ sequence.push(entry);
2036
+ continue;
2037
+ }
2038
+ if (property !== 'deform') {
2039
+ // Reached only by a name OUTSIDE the format: `readAnimation` tests
2040
+ // an attachment timeline for "deform" and "sequence" and reads
2041
+ // nothing else (`SkeletonJson.js:1147-1201`), so no player plays it.
2042
+ note(
2043
+ 'blocker',
2044
+ 'ATTACHMENT_TIMELINE',
2045
+ where,
2046
+ `timeline "${property}" is not an attachment timeline the format has — the runtime's reader tests for ` +
2047
+ '"deform" and "sequence" and ignores anything else, and those two are what the motion spec carries',
2048
+ );
2049
+ continue;
2050
+ }
2051
+ const entry: JsonObject = {
2052
+ slot,
2053
+ attachment,
2054
+ keys: keys.map((raw) => {
2055
+ const key = obj(raw);
2056
+ const out: JsonObject = { t: timeOf(key) };
2057
+ if (key.offset !== undefined) out.offset = key.offset;
2058
+ if (key.vertices !== undefined) out.vertices = key.vertices;
2059
+ easing(key, out);
2060
+ return out;
2061
+ }),
2062
+ };
2063
+ // `skin` is absent for the default skin, which is the spec's own
2064
+ // spelling (`MotionDeformTrack.skin`: absent = "default").
2065
+ if (skinName !== 'default') entry.skin = skinName;
2066
+ deform.push(entry);
2067
+ }
2068
+ }
2069
+ }
2070
+ }
2071
+
2072
+ // drawOrder — inverts `compileDrawOrder`. A key with no `offsets` restores the
2073
+ // setup order; that is the parser's own encoding and the spec spells it the
2074
+ // same way, so an absent array stays absent.
2075
+ let drawOrder: JsonObject[] | undefined;
2076
+ if (Array.isArray(anim.drawOrder)) {
2077
+ drawOrder = anim.drawOrder.map((raw) => {
2078
+ const key = obj(raw);
2079
+ const out: JsonObject = { t: timeOf(key) };
2080
+ if (Array.isArray(key.offsets)) {
2081
+ out.offsets = key.offsets.map((o) => ({ slot: obj(o).slot, offset: obj(o).offset }));
2082
+ }
2083
+ return out;
2084
+ });
2085
+ }
2086
+
2087
+ // events — inverts `compileEvents`. The payload fields are written only where
2088
+ // the firing overrides the declared event's own, which is what the file holds.
2089
+ let events: JsonObject[] | undefined;
2090
+ if (Array.isArray(anim.events)) {
2091
+ events = anim.events.map((raw) => {
2092
+ const key = obj(raw);
2093
+ const out: JsonObject = { t: timeOf(key), name: key.name };
2094
+ for (const field of ['int', 'float', 'string', 'volume', 'balance']) {
2095
+ if (key[field] !== undefined) out[field] = key[field];
2096
+ }
2097
+ return out;
2098
+ });
2099
+ }
2100
+
2101
+ for (const { property, lastKey } of unreached) {
2102
+ const flag = `${property}Global`;
2103
+ note(
2104
+ 'lossy',
2105
+ 'PHYSICS_GLOBAL_REACHES_NOTHING',
2106
+ `animation "${animName}" physics "" ${property}`,
2107
+ 'names no constraint, so the runtime writes it into every physics constraint ' +
2108
+ (property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
2109
+ ', and the rebuild carries ' +
2110
+ (carriedPhysics.length === 0
2111
+ ? 'no physics constraint'
2112
+ : `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
2113
+ ` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
2114
+ 'The motion spec omits it' +
2115
+ (lastKey > maxT
2116
+ ? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
2117
+ : '; the rebuild differs from the source by exactly this no-op'),
2118
+ );
2119
+ }
2120
+
2121
+ for (const group of Object.keys(anim)) {
2122
+ if (ANIMATION_GROUPS.includes(group)) continue;
2123
+ note(
2124
+ 'blocker',
2125
+ 'ANIMATION_GROUP',
2126
+ `animation "${animName}"`,
2127
+ `group "${group}" has no home in the motion spec — an animation group is ${ANIMATION_GROUPS.join(', ')} and ` +
2128
+ 'nothing else, so the rebuild carries nothing from it',
2129
+ );
2130
+ }
2131
+
2132
+ // 🚨 There is no duration in skeleton JSON. The largest key time is the only
2133
+ // derivable answer and it is what a runtime plays to; it is WRONG for an
2134
+ // animation that holds its last pose past its last key, and nothing in the
2135
+ // file distinguishes the two. Recorded per animation rather than hidden.
2136
+ note(
2137
+ 'judgement',
2138
+ 'DURATION',
2139
+ `animation "${animName}"`,
2140
+ `skeleton JSON carries no duration; the largest key time (${maxT}) is used, which is what a runtime plays to. ` +
2141
+ 'An animation meant to hold past its last key needs the real number stated by hand',
2142
+ );
2143
+
2144
+ const out: JsonObject = { duration: maxT, tracks };
2145
+ if (ik.length) out.ik = ik;
2146
+ if (transform.length) out.transform = transform;
2147
+ if (deform.length) out.deform = deform;
2148
+ if (sequence.length) out.sequence = sequence;
2149
+ if (drawOrder !== undefined) out.drawOrder = drawOrder;
2150
+ if (events !== undefined) out.events = events;
2151
+ return out;
2152
+ }
2153
+
2154
+ /**
2155
+ * `ik` / `transform` — one unnamed timeline per constraint.
2156
+ *
2157
+ * Inverts `compileConstraintTrack`, and this is the one inversion that has to
2158
+ * RESTATE rather than copy. Two reasons, and neither invents a value:
2159
+ *
2160
+ * 1. **The uniform field set.** Every field of these keys is optional with a
2161
+ * per-key default, so `compileConstraintTrack` refuses a track whose keys do
2162
+ * not all name the same fields — *"state it on every key or on none"*. An
2163
+ * export does not obey that: it omits a field wherever it equals the default.
2164
+ * So a field ANY key states is written on EVERY key, at the value the parser
2165
+ * would have read there. Identical semantics — and, since issue #716, the
2166
+ * same file wherever `PARSER_DEFAULTS` has the key's row, because the
2167
+ * emitter leaves each such value out again. What is still reported is a
2168
+ * value the emitter writes back.
2169
+ * 2. 🚨 **`rigFlags`.** `compileConstraintTrack` stamps the rig constraint's
2170
+ * non-default `bendPositive`/`compress`/`stretch` onto a key that omits one
2171
+ * (issue #273). On rigc's own output that is self-consistent — rigc already
2172
+ * wrote the flag on every key, so it is read back as stated. On a FOREIGN
2173
+ * export it would change what plays: the export's omission means the per-key
2174
+ * default, and the stamp would substitute the constraint's setup value. So a
2175
+ * flag the constraint declares non-default is written on every key at the
2176
+ * PARSER default, which is what the export actually plays.
2177
+ */
2178
+ function constraintGroup(
2179
+ group: 'ik' | 'transform',
2180
+ animName: string,
2181
+ anim: JsonObject,
2182
+ root: JsonObject,
2183
+ defaults: Record<string, number | boolean | string>,
2184
+ seeT: (t: number) => void,
2185
+ note: Note,
2186
+ ): JsonObject[] {
2187
+ const fields = Object.keys(defaults);
2188
+ const out: JsonObject[] = [];
2189
+ for (const [name, keys] of arrEntries(anim[group])) {
2190
+ const where = `animation "${animName}" ${group} "${name}"`;
2191
+ const stated = new Set<string>();
2192
+ for (const raw of keys) {
2193
+ const key = obj(raw);
2194
+ for (const field of fields) if (key[field] !== undefined) stated.add(field);
2195
+ }
2196
+ if (group === 'ik') {
2197
+ const constraint = arr(root.constraints)
2198
+ .map(obj)
2199
+ .find((c) => nameOf(c) === name && c.type === 'ik');
2200
+ for (const flag of IK_FLAGS) {
2201
+ if (constraint !== undefined && constraint[flag] !== undefined && constraint[flag] !== IK_KEY_DEFAULTS[flag]) {
2202
+ stated.add(flag);
2203
+ }
2204
+ }
2205
+ }
2206
+ // A field filled on a key the source left it off is said only where the
2207
+ // emitter will write it: where `PARSER_DEFAULTS`' row for the key would
2208
+ // leave it out again, the rebuild is the source's own text (issue #716).
2209
+ const row = PARSER_DEFAULTS[`${group} key`];
2210
+ let restated = 0;
2211
+ const ks = keys.map((raw) => {
2212
+ const key = obj(raw);
2213
+ const entry: JsonObject = { t: typeof key.time === 'number' ? key.time : 0 };
2214
+ seeT(entry.t as number);
2215
+ const filled: string[] = [];
2216
+ for (const field of fields) {
2217
+ if (!stated.has(field)) continue;
2218
+ if (key[field] !== undefined) entry[field] = key[field];
2219
+ else {
2220
+ const dflt = defaults[field];
2221
+ // `mixY`'s default is the same key's own `mixX`, spelled as that field
2222
+ // name in the table above.
2223
+ entry[field] = typeof dflt === 'string' ? (key[dflt] !== undefined ? key[dflt] : defaults[dflt]) : dflt;
2224
+ filled.push(field);
2225
+ }
2226
+ }
2227
+ if (filled.length > 0) {
2228
+ const emitted: JsonObject = { ...key };
2229
+ for (const field of fields) if (entry[field] !== undefined) emitted[field] = entry[field];
2230
+ const site = { object: emitted, previous: () => null };
2231
+ restated += filled.filter((field) => row === undefined || !parserOmits(row, site, field)).length;
2232
+ }
2233
+ if (key.curve === 'stepped') entry.ease = 'stepped';
2234
+ else if (key.curve !== undefined) entry.curve = key.curve;
2235
+ for (const field of Object.keys(key)) {
2236
+ if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
2237
+ note('blocker', `${group.toUpperCase()}_KEY_FIELD`, where, `key field "${field}" is not part of this timeline's shape`);
2238
+ }
2239
+ return entry;
2240
+ });
2241
+ if (restated > 0) {
2242
+ note(
2243
+ 'lossy',
2244
+ 'CONSTRAINT_KEY_RESTATED',
2245
+ where,
2246
+ `${restated} value(s) the source omits are restated at the parser's default, because the motion spec ` +
2247
+ 'requires one field set per track and the emitter writes them back — the same values the runtime reads, ' +
2248
+ 'spelled out',
2249
+ );
2250
+ }
2251
+ out.push({ constraint: name, keys: ks });
2252
+ }
2253
+ return out;
2254
+ }
2255
+
2256
+ /**
2257
+ * The provenance sentence both specs carry (INGEST §2.4's rule).
2258
+ *
2259
+ * ⚠️ A decompiled spec is indistinguishable from an authored one by inspection,
2260
+ * and every gate in this tree will call it green — because it IS green. No gate
2261
+ * catches a missing note, which is exactly why `ingest` writes one itself rather
2262
+ * than leaving it to the caller.
2263
+ *
2264
+ * 🔒 No timestamp, and that is a contract rather than a style: `A18` compares two
2265
+ * independent compiles byte for byte, and a dated note in a spec would break the
2266
+ * first rebuild from it.
2267
+ */
2268
+ function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion', consumerDriven = false): string {
2269
+ const head =
2270
+ `DECOMPILED from ${opts.source} by \`rigc ingest\` ${opts.version}. Every number here was read out of that ` +
2271
+ 'skeleton; nothing was authored, so this file says what the object IS and nothing about why.';
2272
+ if (which === 'rig') {
2273
+ // Only where the declaration was written, so every other decompiled rig spec
2274
+ // keeps its note byte for byte.
2275
+ if (consumerDriven) {
2276
+ return (
2277
+ `${head} \`invariants\` holds only \`consumerDrivenMix\`, the constraints this run read as mixes the ` +
2278
+ 'consumer sets (each is a CONSUMER_DRIVEN_MIX finding); it turns a refusal into a SKIP and certifies ' +
2279
+ 'nothing. A skeleton declares no other invariant, and an assertion with nothing to measure must SKIP ' +
2280
+ 'rather than pass.'
2281
+ );
2282
+ }
2283
+ return (
2284
+ `${head} \`invariants\` is deliberately absent — a skeleton declares none, and an assertion with nothing to ` +
2285
+ 'measure must SKIP rather than pass.'
2286
+ );
2287
+ }
2288
+ return (
2289
+ `${head} Every curve is a raw \`curve\` array — the absolute (time, value) control points verbatim — because an ` +
2290
+ 'export carries a different bezier per key per channel and no named easing can say that. Each `duration` is the ' +
2291
+ 'largest key time in its animation, which is the only figure skeleton JSON supports.'
2292
+ );
2293
+ }