spine-rigc 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ingest.ts ADDED
@@ -0,0 +1,1137 @@
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 both are `judgement` findings; every construct the
28
+ * spec format cannot hold is a `blocker`; everything rigc re-derives rather than
29
+ * carries is `lossy`.
30
+ *
31
+ * ## What it does not read
32
+ *
33
+ * Skeleton JSON, and nothing else. Not the atlas, not a `.spine` project, not a
34
+ * binary `.skel`, not the art. A rig spec's texture side is therefore the
35
+ * caller's (`IngestOptions.art`) and is stated as such.
36
+ *
37
+ * ## Purity
38
+ *
39
+ * No clock, no randomness, no filesystem, no network, and no `spine-core` — the
40
+ * three files allowed to link the runtime are named in CLAUDE.md and this is not
41
+ * one of them (`CUR07` refuses a fourth). The provenance `note` therefore carries
42
+ * a version the caller passes in and **no timestamp**, because a timestamp would
43
+ * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
44
+ * spec.
45
+ */
46
+ import { SPINE_VERSION } from './compile.ts';
47
+ import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
48
+ import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
49
+ import type { MotionSpec } from './types.ts';
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // findings
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * What a finding is about, which is also what the caller's exit code turns on.
57
+ *
58
+ * - `blocker` — the spec format cannot say this, so the rebuilt skeleton will
59
+ * NOT be the one that was read. Non-zero exit.
60
+ * - `judgement` — the skeleton does not carry it and somebody has to decide.
61
+ * There are exactly two: the stage, and an animation's duration.
62
+ * - `lossy` — the skeleton carries it and rigc re-derives it rather than taking
63
+ * it, which is correct and is said out loud (`lengths`, the `spine` version).
64
+ */
65
+ export type IngestFindingKind = 'blocker' | 'judgement' | 'lossy';
66
+
67
+ export interface IngestFinding {
68
+ /** Stable code, so a table can count them and a doc can name one. */
69
+ code: string;
70
+ /** The object this is about, named the way a validator failure names one. */
71
+ where: string;
72
+ /** One sentence: what was found, and what it means for the rebuild. */
73
+ detail: string;
74
+ kind: IngestFindingKind;
75
+ }
76
+
77
+ /** The stage a skeleton does not carry. See `NO_STAGE` below. */
78
+ export interface IngestStage {
79
+ x: number;
80
+ y: number;
81
+ width: number;
82
+ height: number;
83
+ }
84
+
85
+ export interface IngestOptions {
86
+ /** The rig spec's `name`, which the motion spec's `archetype` must equal. */
87
+ name: string;
88
+ /**
89
+ * How the rebuilt spec gets at the art.
90
+ *
91
+ * `loose` names an `image` per attachment, so `build --images <dir>` measures
92
+ * the PNGs; `none` states `width`/`height` only, for a rebuild that resolves
93
+ * through `build --atlas-in <pack>`. The skeleton encodes neither, which is
94
+ * why this is a flag rather than a derivation.
95
+ */
96
+ art: 'loose' | 'none';
97
+ /**
98
+ * The rig spec's own `images` — the directory every `image` written below
99
+ * resolves against — ALREADY spelled relative to the directory the spec will
100
+ * be written into (issue #595).
101
+ *
102
+ * Absent leaves the field out, and an absent `images` resolves against the
103
+ * spec's own directory: a caller who extracted the parts anywhere else then
104
+ * carries `build --images <dir>` on every rebuild forever, and a spec that
105
+ * needs a flag to build is a spec whose `note` would have to say so.
106
+ *
107
+ * ⚠️ Spelled by the CALLER, not here. Turning a directory somebody typed into
108
+ * an absolute path reads a working directory, and this module reads nothing;
109
+ * `cli.ts` resolves it and spells it with `relativeImagesPath`, which is the
110
+ * same function `build` spells `skeleton.images` with, so the two cannot
111
+ * drift. Meaningless under `art: 'none'`, which writes no `image` at all —
112
+ * `cli.ts` refuses that pair rather than writing a field nothing reads.
113
+ */
114
+ images?: string;
115
+ /** Supplied stage. Used ONLY when the skeleton carries no width/height. */
116
+ stage?: IngestStage;
117
+ /** The source file's basename, for the provenance note. No path: no leak. */
118
+ source: string;
119
+ /** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
120
+ version: string;
121
+ }
122
+
123
+ export interface IngestResult {
124
+ rig: RigSpec;
125
+ motion: MotionSpec;
126
+ findings: IngestFinding[];
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // JSON narrowing — the input is a file somebody else wrote
131
+ // ---------------------------------------------------------------------------
132
+
133
+ type JsonObject = Record<string, unknown>;
134
+
135
+ function isObj(v: unknown): v is JsonObject {
136
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
137
+ }
138
+
139
+ /** The array at `v`, or an empty one. An absent collection is not a fault here. */
140
+ function arr(v: unknown): unknown[] {
141
+ return Array.isArray(v) ? v : [];
142
+ }
143
+
144
+ /** The object at `v`, or an empty one. */
145
+ function obj(v: unknown): JsonObject {
146
+ return isObj(v) ? v : {};
147
+ }
148
+
149
+ /** `Object.entries` over the OBJECT-valued entries of `v`, in file order. */
150
+ function objEntries(v: unknown): Array<[string, JsonObject]> {
151
+ return Object.entries(obj(v)).filter((entry): entry is [string, JsonObject] => isObj(entry[1]));
152
+ }
153
+
154
+ /** `Object.entries` over the ARRAY-valued entries of `v`, in file order. */
155
+ function arrEntries(v: unknown): Array<[string, unknown[]]> {
156
+ return Object.entries(obj(v)).filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]));
157
+ }
158
+
159
+ /** The numbers at `v`, or undefined. A mixed array is not a number array. */
160
+ function numbers(v: unknown): number[] | undefined {
161
+ if (!Array.isArray(v)) return undefined;
162
+ return v.every((n) => typeof n === 'number') ? (v as number[]) : undefined;
163
+ }
164
+
165
+ function nameOf(v: unknown): string {
166
+ return isObj(v) && typeof v.name === 'string' ? v.name : '';
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // field tables — DERIVED from the rig spec's own key sets, never retyped
171
+ // ---------------------------------------------------------------------------
172
+ //
173
+ // ⭐ `RIG_KEYS` is already the statement of which fields a rig spec holds, and
174
+ // `checkRigSpecKeys` refuses everything outside it. Reading the carry list off
175
+ // that table rather than copying it means a field added to the spec becomes
176
+ // carryable here with no edit, and — the half that matters — a skeleton field
177
+ // that has no rig-spec home is refused BY NAME instead of being dropped in
178
+ // silence. A second list would be two lists that have to agree, which is the
179
+ // defect `RIG_KEYS`'s own comment is about.
180
+
181
+ /** Everything `RIG_KEYS` names for a shape, minus the keys this file handles itself. */
182
+ function carried(shape: keyof typeof RIG_KEYS, ...handled: string[]): string[] {
183
+ return (RIG_KEYS[shape] as readonly string[]).filter((key) => !handled.includes(key));
184
+ }
185
+
186
+ /**
187
+ * Bone fields carried verbatim. Inverts `buildBone` in `compile.ts`.
188
+ *
189
+ * `from` is excluded because it is rigc's own: a bone position taken from a
190
+ * manifest anchor, resolved to `x`/`y` at compile time (`cropPointOf`). A
191
+ * skeleton holds the resolved numbers and nothing else, so carrying them as
192
+ * `x`/`y` is the inversion and a `from` here would be an invention.
193
+ */
194
+ const BONE_FIELDS = carried('RigBone', 'name', 'from');
195
+
196
+ /** Slot fields carried verbatim. Inverts the slot loop in `compile()` step 4. */
197
+ const SLOT_FIELDS = carried('RigSlot', 'name');
198
+
199
+ /** Per constraint type, the fields carried verbatim. Inverts `buildRigConstraint`. */
200
+ const CONSTRAINT_FIELDS: Record<string, string[]> = {
201
+ ik: carried('RigIkConstraint', 'name', 'type'),
202
+ transform: carried('RigTransformConstraint', 'name', 'type'),
203
+ path: carried('RigPathConstraint', 'name', 'type'),
204
+ physics: carried('RigPhysicsConstraint', 'name', 'type'),
205
+ slider: carried('RigSliderConstraint', 'name', 'type'),
206
+ };
207
+
208
+ /**
209
+ * The per-skin member lists (`SkeletonJson` reads them before `attachments`).
210
+ *
211
+ * `RIG_KEYS.RigSkinEntry` is `attachments` plus the five constraint lists plus
212
+ * `bones`; the long form of a skin is exactly those, so the list is that set
213
+ * minus the table itself.
214
+ */
215
+ const SKIN_LISTS = carried('RigSkinEntry', 'attachments');
216
+
217
+ /**
218
+ * One value-track channel: the JSON field, and what the PARSER reads where a key
219
+ * omits it.
220
+ *
221
+ * ⚠️ A number is a constant default; a STRING is another field of the same key,
222
+ * which is how `mixY` works (`SkeletonJson:988` — it defaults to that key's own
223
+ * `mixX`, not to 1). The same two-shaped table as `CONSTRAINT_TIMELINES`'s
224
+ * `inheritsFrom`, and for the same reason.
225
+ */
226
+ type TrackShape = Array<[field: string, dflt: number | string]>;
227
+
228
+ /**
229
+ * Bone timeline shapes. Inverts `BONE_TRACKS` + `compileValueTrack`.
230
+ *
231
+ * 🚨 The defaults matter more than they look, and they are not all the same:
232
+ * Spine omits a field that equals the SETUP value, `translate` reads 0 there and
233
+ * `scale` reads 1. A decompiler that filled every omission with 0 would collapse
234
+ * every scale key it read, silently.
235
+ */
236
+ const BONE_TRACKS: Record<string, TrackShape> = {
237
+ translate: [['x', 0], ['y', 0]],
238
+ translatex: [['value', 0]],
239
+ translatey: [['value', 0]],
240
+ scale: [['x', 1], ['y', 1]],
241
+ scalex: [['value', 1]],
242
+ scaley: [['value', 1]],
243
+ shear: [['x', 0], ['y', 0]],
244
+ shearx: [['value', 0]],
245
+ sheary: [['value', 0]],
246
+ rotate: [['value', 0]],
247
+ };
248
+
249
+ /**
250
+ * The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
251
+ * `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
252
+ *
253
+ * 🚨 The defaults are the parser's per-key ones and they are **0 on six of the
254
+ * eight**, which is not where a reader looks for them: the constraint's own
255
+ * defaults (`inertia` 0.5, `strength` 100, `damping` 0.85, `mass` 1) sit in the
256
+ * same file at `:306-312` and belong to the constraint, not to a key. A
257
+ * decompiler that filled an omitted `damping` key with 0.85 would write a spec
258
+ * that plays a different animation from the one it read, and every gate would
259
+ * call it green. Copied off `SkeletonJson.js:1062` and `:1090`, not assumed.
260
+ */
261
+ const PHYSICS_TRACKS: Record<string, TrackShape> = {
262
+ inertia: [['value', 0]],
263
+ strength: [['value', 0]],
264
+ damping: [['value', 0]],
265
+ mass: [['value', 0]],
266
+ wind: [['value', 0]],
267
+ gravity: [['value', 0]],
268
+ mix: [['value', 1]],
269
+ reset: [],
270
+ };
271
+
272
+ /** `mix` is three values in ONE key — `PATH_TRACKS` in `compile.ts`. */
273
+ const PATH_TRACKS: Record<string, TrackShape> = {
274
+ position: [['value', 0]],
275
+ spacing: [['value', 0]],
276
+ mix: [['mixRotate', 1], ['mixX', 1], ['mixY', 'mixX']],
277
+ };
278
+
279
+ /** ⚠️ `time`'s per-key default is **1**, not 0 (`:1121`). Copied, not assumed. */
280
+ const SLIDER_TRACKS: Record<string, TrackShape> = { time: [['value', 1]], mix: [['value', 1]] };
281
+
282
+ /**
283
+ * The `ik` and `transform` key fields and the value the PARSER reads where a key
284
+ * omits one — `CONSTRAINT_TIMELINES` in `compile.ts`, channels then flags.
285
+ *
286
+ * ⚠️ `mixY` is the one field whose default is not a constant: it is the same
287
+ * key's own `mixX` (`SkeletonJson:988`), which is why it is spelled here as a
288
+ * field name rather than a number.
289
+ */
290
+ const IK_KEY_DEFAULTS: Record<string, number | boolean> = {
291
+ mix: 1,
292
+ softness: 0,
293
+ bendPositive: true,
294
+ compress: false,
295
+ stretch: false,
296
+ };
297
+ const TRANSFORM_KEY_DEFAULTS: Record<string, number | boolean | string> = {
298
+ mixRotate: 1,
299
+ mixX: 1,
300
+ mixY: 'mixX',
301
+ mixScaleX: 1,
302
+ mixScaleY: 1,
303
+ mixShearY: 1,
304
+ };
305
+ /** The three ik booleans, which `compileConstraintTrack` stamps from the rig. */
306
+ const IK_FLAGS = ['bendPositive', 'compress', 'stretch'];
307
+
308
+ /** The animation groups `readAnimation` reads. Anything else is a blocker. */
309
+ const ANIMATION_GROUPS = ['bones', 'slots', 'ik', 'transform', 'path', 'physics', 'slider', 'attachments', 'drawOrder', 'events'];
310
+
311
+ /** The header fields rigc writes that no rig spec field holds. */
312
+ const HEADER_REDERIVED = ['spine'];
313
+
314
+ /** The attachment types this module inverts. Everything else is refused by name. */
315
+ const ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'];
316
+
317
+ /** The two slot timelines the motion spec carries (`compileTrack`'s two branches). */
318
+ const SLOT_TRACKS = ['rgba', 'attachment'];
319
+
320
+ /**
321
+ * Everything this module has a branch for, as the branches themselves state it.
322
+ *
323
+ * ⭐ It exists so that a gate can ask the question a suite cannot answer from a
324
+ * list somebody typed: **is every construct `ingest` claims to carry actually
325
+ * exercised by a rig somebody builds?** A decompiler branch no rig reaches is a
326
+ * branch nobody has seen work, which is this repository's own definition of not
327
+ * a gate — and the vocabulary has to come from here, because a second copy in
328
+ * `selftest.ts` would go stale in exactly the direction that hides the hole.
329
+ */
330
+ export const INGEST_VOCABULARY = {
331
+ attachments: ATTACHMENT_TYPES,
332
+ constraints: Object.keys(CONSTRAINT_FIELDS),
333
+ boneTracks: Object.keys(BONE_TRACKS),
334
+ slotTracks: SLOT_TRACKS,
335
+ path: Object.keys(PATH_TRACKS),
336
+ physics: Object.keys(PHYSICS_TRACKS),
337
+ slider: Object.keys(SLIDER_TRACKS),
338
+ animationGroups: ANIMATION_GROUPS,
339
+ } as const satisfies Record<string, readonly string[]>;
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // the inversions
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Spine's flat weight run back into one `{bone, x, y, weight}` list per vertex.
347
+ *
348
+ * 🔒 Inverts `encodeNamedWeights`, and the inversion is **by name** for exactly
349
+ * the reason that function encodes by name: the run holds positions in the
350
+ * EMITTED bone array, a list no spec writes, so a decompiled `vertices` run
351
+ * would rebind every vertex the moment a bone moved in the array (issue #45).
352
+ * The names are the join key on both sides.
353
+ */
354
+ function decodeWeights(vertices: readonly number[], boneNames: readonly string[]): Array<Array<Record<string, unknown>>> {
355
+ const out: Array<Array<Record<string, unknown>>> = [];
356
+ let i = 0;
357
+ while (i < vertices.length) {
358
+ const count = vertices[i++];
359
+ const vertex: Array<Record<string, unknown>> = [];
360
+ for (let k = 0; k < count; k++) {
361
+ vertex.push({ bone: boneNames[vertices[i]], x: vertices[i + 1], y: vertices[i + 2], weight: vertices[i + 3] });
362
+ i += 4;
363
+ }
364
+ out.push(vertex);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ /**
370
+ * `"rrggbbaa"` back to `[r, g, b, a]` in 0..1. Inverts `rgbaHex`.
371
+ *
372
+ * ⚠️ One byte per channel is all the file holds, so this is exact in the only
373
+ * direction that matters: the rebuild quantises the same floats to the same
374
+ * bytes. It is not a recovery of whatever the original author typed.
375
+ */
376
+ function hexToRgba(hex: string): number[] {
377
+ return [0, 2, 4, 6].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
378
+ }
379
+
380
+ /**
381
+ * Which placeholders more than one skin fills, per slot.
382
+ *
383
+ * 🔒 Inverts `contestedPlaceholders` + `nameSkinAttachment`: rigc writes an
384
+ * attachment `name` exactly where a placeholder is contested, and the rig spec
385
+ * has no field for one. So the name is DROPPED and re-derived — which is right
386
+ * where the contest survives the round trip, and a loss anywhere else. This is
387
+ * what lets that difference be reported rather than assumed.
388
+ */
389
+ function contestedPlaceholders(skins: readonly unknown[]): Map<string, Set<string>> {
390
+ const fillers = new Map<string, Map<string, number>>();
391
+ for (const skin of skins) {
392
+ for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
393
+ const perSlot = fillers.get(slot) ?? new Map<string, number>();
394
+ for (const placeholder of Object.keys(placeholders)) {
395
+ perSlot.set(placeholder, (perSlot.get(placeholder) ?? 0) + 1);
396
+ }
397
+ fillers.set(slot, perSlot);
398
+ }
399
+ }
400
+ const contested = new Map<string, Set<string>>();
401
+ for (const [slot, perSlot] of fillers) {
402
+ const shared = new Set([...perSlot].filter(([, count]) => count > 1).map(([placeholder]) => placeholder));
403
+ if (shared.size) contested.set(slot, shared);
404
+ }
405
+ return contested;
406
+ }
407
+
408
+ /**
409
+ * Read a skeleton, write the two specs that rebuild it.
410
+ *
411
+ * Pure: the same skeleton and the same options give the same specs, every time.
412
+ * The two returned specs have been through `parseRigSpec` and `parseMotionSpec`
413
+ * before they leave — a decompiler that hands back something the compiler's own
414
+ * parser would refuse has produced a file nobody can use, and saying so here
415
+ * names the decompiler instead of leaving `build` to name the file.
416
+ */
417
+ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
418
+ const findings: IngestFinding[] = [];
419
+ const note = (kind: IngestFindingKind, code: string, where: string, detail: string): void => {
420
+ findings.push({ code, where, detail, kind });
421
+ };
422
+
423
+ const root = obj(skeleton);
424
+ const boneNames: string[] = arr(root.bones).map(nameOf);
425
+
426
+ // -- header ---------------------------------------------------------------
427
+ // 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
428
+ // stage is the only value in this whole module that a skeleton cannot answer
429
+ // for. Issue #578 is landing a way for a rig spec to SAY that a skeleton
430
+ // declares no stage; when it does, this is the one place that changes.
431
+ const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
432
+
433
+ // -- bones ----------------------------------------------------------------
434
+ // Inverts `buildBone`, which copies every declared field and omits the rest.
435
+ const bones = arr(root.bones).map((raw) => {
436
+ const bone = obj(raw);
437
+ const out: JsonObject = { name: bone.name };
438
+ for (const field of BONE_FIELDS) if (bone[field] !== undefined) out[field] = bone[field];
439
+ for (const key of Object.keys(bone)) {
440
+ if (key === 'name' || BONE_FIELDS.includes(key)) continue;
441
+ note('blocker', 'BONE_FIELD', `bone "${nameOf(bone)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
442
+ }
443
+ return out;
444
+ });
445
+
446
+ // -- slots ----------------------------------------------------------------
447
+ // A slot some skin fills but that shows nothing in the setup pose carries NO
448
+ // `attachment` field, and `build` refuses a filled slot with no setup pose
449
+ // ("the compiler will not guess one"). The skeleton does state it: an absent
450
+ // `attachment` on a filled slot means "show nothing", which the rig spec
451
+ // spells `null`. Transcribing an absence is not inventing a value.
452
+ const filled = new Set<string>();
453
+ for (const skin of arr(root.skins)) {
454
+ for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
455
+ if (Object.keys(placeholders).length) filled.add(slot);
456
+ }
457
+ }
458
+ const slots = arr(root.slots).map((raw) => {
459
+ const slot = obj(raw);
460
+ const out: JsonObject = { name: slot.name };
461
+ for (const field of SLOT_FIELDS) if (slot[field] !== undefined) out[field] = slot[field];
462
+ if (out.attachment === undefined && filled.has(nameOf(slot))) out.attachment = null;
463
+ for (const key of Object.keys(slot)) {
464
+ if (key === 'name' || SLOT_FIELDS.includes(key)) continue;
465
+ note('blocker', 'SLOT_FIELD', `slot "${nameOf(slot)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
466
+ }
467
+ return out;
468
+ });
469
+
470
+ // -- skins and attachments ------------------------------------------------
471
+ const contested = contestedPlaceholders(arr(root.skins));
472
+ const skins: JsonObject = {};
473
+ for (const skin of arr(root.skins)) {
474
+ const entry = obj(skin);
475
+ const table: JsonObject = {};
476
+ for (const [slot, placeholders] of objEntries(entry.attachments)) {
477
+ const perSlot: JsonObject = {};
478
+ for (const [placeholder, att] of Object.entries(placeholders)) {
479
+ perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
480
+ where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
481
+ contested: contested.get(slot)?.has(placeholder) === true,
482
+ });
483
+ }
484
+ table[slot] = perSlot;
485
+ }
486
+ const lists: JsonObject = {};
487
+ let anyList = false;
488
+ for (const list of SKIN_LISTS) {
489
+ if (entry[list] !== undefined) {
490
+ lists[list] = entry[list];
491
+ anyList = true;
492
+ }
493
+ }
494
+ // The long form only where the skin activates something; otherwise the short
495
+ // form, which is what every rig in this tree writes and what `splitRigSkin`
496
+ // reads back as the bare attachment table.
497
+ skins[nameOf(entry)] = anyList ? { ...lists, attachments: table } : table;
498
+ }
499
+
500
+ // -- constraints ----------------------------------------------------------
501
+ // Inverts `buildRigConstraint`. 4.3 puts every type in ONE array and branches
502
+ // on `type`, so an unknown `type` is refused here for the same reason the
503
+ // parser's silence about it is assertion A01: it would simply vanish.
504
+ const constraints: JsonObject[] = [];
505
+ for (const raw of arr(root.constraints)) {
506
+ const constraint = obj(raw);
507
+ const type = typeof constraint.type === 'string' ? constraint.type : '';
508
+ const fields = CONSTRAINT_FIELDS[type];
509
+ const who = `constraint "${nameOf(constraint)}"`;
510
+ if (fields === undefined) {
511
+ note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
512
+ continue;
513
+ }
514
+ const out: JsonObject = { name: constraint.name, type };
515
+ for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
516
+ for (const key of Object.keys(constraint)) {
517
+ if (key === 'name' || key === 'type' || fields.includes(key)) continue;
518
+ note('blocker', 'CONSTRAINT_FIELD', `${who} (${type})`, `field "${key}" has no rig-spec field, so it is dropped`);
519
+ }
520
+ constraints.push(out);
521
+ }
522
+
523
+ // -- animations -----------------------------------------------------------
524
+ const animations: JsonObject = {};
525
+ for (const [animName, raw] of objEntries(root.animations)) {
526
+ animations[animName] = ingestAnimation(animName, raw, root, note);
527
+ }
528
+
529
+ // -- assemble -------------------------------------------------------------
530
+ const rig: JsonObject = {
531
+ spec: RIG_SPEC_VERSION,
532
+ name: opts.name,
533
+ note: provenanceNote(opts, 'rig'),
534
+ };
535
+ if (Object.keys(rigHeader).length) rig.skeleton = rigHeader;
536
+ // Between `skeleton` and `bones`, which is where `RIG_KEYS.RigSpec` puts it —
537
+ // this file's key order is the spec's declared order and nothing sorts it.
538
+ if (opts.images !== undefined) rig.images = opts.images;
539
+ rig.bones = bones;
540
+ rig.slots = slots;
541
+ if (Object.keys(skins).length) rig.skins = skins;
542
+ if (constraints.length) rig.constraints = constraints;
543
+ if (isObj(root.events)) rig.events = { ...root.events };
544
+ // `invariants` is deliberately absent. A skeleton states no invariant, and
545
+ // INGEST §2.1 already says what to do about that: leave the block out, because
546
+ // an assertion with nothing to measure reports SKIP and never a pass. Writing
547
+ // an invariant here would be certifying a rig nobody measured.
548
+
549
+ const motion: JsonObject = {
550
+ spec: MOTION_SPEC_VERSION,
551
+ archetype: opts.name,
552
+ cut: opts.name,
553
+ note: provenanceNote(opts, 'motion'),
554
+ // Empty on purpose: every curve below is written as a RAW `curve` array.
555
+ // A named easing says "this shape, wherever it is used" and an export carries
556
+ // a different bezier per key per channel, so there is no named easing to
557
+ // recognise — only a shape to copy. `easingCurve`'s output is what a raw
558
+ // curve holds, which is why the rebuild is byte-identical either way.
559
+ easings: {},
560
+ animations,
561
+ };
562
+
563
+ return {
564
+ // 🔒 Through the tree's own parsers before they leave. `parseRigSpec` and
565
+ // `parseMotionSpec` are what `build` reads these files with, so a spec this
566
+ // module could produce and `build` would refuse is named here, at the
567
+ // decompiler, rather than three commands later at the file.
568
+ rig: parseRigSpec(rig, `ingest(${opts.source}): rig spec`),
569
+ motion: parseMotionSpec(motion, `ingest(${opts.source}): motion spec`),
570
+ findings,
571
+ };
572
+ }
573
+
574
+ type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
575
+
576
+ /**
577
+ * The rig spec's `skeleton` block — and the one judgement in this module.
578
+ *
579
+ * 🚨 **A skeleton JSON need not carry the stage, and it cannot be derived.** rigc
580
+ * always emits `x`/`y`/`width`/`height`, so a rigc build round-trips with nothing
581
+ * to decide. `compile` refuses without one, and there is no derivation: posing
582
+ * the rig gives the ANIMATED extent, which is a different number from the
583
+ * editor's setup box. So with no `--stage` this records a blocker naming the
584
+ * field and writes nothing plausible.
585
+ *
586
+ * ⚠️ This said an editor export's `skeleton` block is `hash`, `spine`, `images`,
587
+ * `audio` **and no box at all** until issue #594 measured the corpus: all twelve
588
+ * exports under `examples/` carry `x`/`y`/`width`/`height`, and the early return
589
+ * below is the branch they take. The blocker is for a file that really has none,
590
+ * and this module has no example of one.
591
+ *
592
+ * ⭐ It is still the judgement that costs least to get wrong. `diff` does report
593
+ * the box — `stage_present` and `stage_box`, since issue #578 — but they sit in
594
+ * the `(reported)` block that no rung consults, so a deliberately absurd unit box
595
+ * is green everywhere a candidate is scored.
596
+ */
597
+ function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonObject {
598
+ const out: JsonObject = {};
599
+ for (const field of RIG_KEYS.RigSkeletonHeader) if (head[field] !== undefined) out[field] = head[field];
600
+ for (const key of Object.keys(head)) {
601
+ if ((RIG_KEYS.RigSkeletonHeader as readonly string[]).includes(key)) continue;
602
+ if (HEADER_REDERIVED.includes(key)) {
603
+ // Two-sided on purpose: the same fact reads as bookkeeping when the two
604
+ // agree and as a warning when they do not, and a reader needs to be told
605
+ // which — a rebuild of a 4.2 export states 4.3, in one field, silently.
606
+ const same = head[key] === SPINE_VERSION;
607
+ note(
608
+ 'lossy',
609
+ 'HEADER_REDERIVED',
610
+ `skeleton.${key}`,
611
+ `the source states ${JSON.stringify(head[key])} and the rig spec has no field for it: a rebuild writes the ` +
612
+ `version of the runtime rigc links, ${SPINE_VERSION}` +
613
+ (same ? ', which is the same string, so nothing moves' : ' — so this field WILL change on the rebuild'),
614
+ );
615
+ continue;
616
+ }
617
+ note(
618
+ 'lossy',
619
+ 'HEADER_BOOKKEEPING',
620
+ `skeleton.${key}`,
621
+ `the editor writes "${key}" and the rig spec has no field for it; it is dropped and nothing reads it back`,
622
+ );
623
+ }
624
+ if (out.width !== undefined && out.height !== undefined) return out;
625
+ if (opts.stage === undefined) {
626
+ note(
627
+ 'blocker',
628
+ 'NO_STAGE',
629
+ 'skeleton.width/height',
630
+ 'the skeleton declares no stage; give --stage x,y,w,h — the value is the caller\'s, not derived. Posing the ' +
631
+ 'rig would give the ANIMATED extent, which is a different number from the setup box, so rigc refuses rather ' +
632
+ 'than measuring the wrong thing (or state the absence once the spec can)',
633
+ );
634
+ return out;
635
+ }
636
+ Object.assign(out, opts.stage);
637
+ note(
638
+ 'judgement',
639
+ 'NO_STAGE',
640
+ 'skeleton.width/height',
641
+ `the skeleton declares no stage and the caller supplied ${opts.stage.x},${opts.stage.y},${opts.stage.width},` +
642
+ `${opts.stage.height}. Nothing measured it: no gate in this tree reads the skeleton header, so a wrong box is ` +
643
+ 'green everywhere (or state the absence once the spec can)',
644
+ );
645
+ return out;
646
+ }
647
+
648
+ /**
649
+ * One attachment, by type. Inverts `buildRigAttachment`'s five branches.
650
+ *
651
+ * The types rigc does not emit are refused BY NAME rather than dropped, which is
652
+ * the same reason `buildRigAttachment` refuses them: the parser's own behaviour
653
+ * on a type it does not know is to return null and carry on, so a decompiler
654
+ * that skipped one would hand back a spec that is quietly missing an attachment.
655
+ */
656
+ function ingestAttachment(
657
+ att: JsonObject,
658
+ placeholder: string,
659
+ boneNames: readonly string[],
660
+ opts: IngestOptions,
661
+ note: Note,
662
+ at: { where: string; contested: boolean },
663
+ ): JsonObject {
664
+ // `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
665
+ // does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
666
+ const type = att.type === undefined ? 'region' : String(att.type);
667
+ const out: JsonObject = {};
668
+
669
+ // `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
670
+ // where a placeholder is contested — so the rig spec has no field for it and
671
+ // it is dropped. Where the contest survives the round trip the same name comes
672
+ // back; anywhere else it is a real loss and is reported.
673
+ if (att.name !== undefined && !at.contested) {
674
+ note(
675
+ 'lossy',
676
+ 'ATTACHMENT_NAME',
677
+ at.where,
678
+ `the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
679
+ 'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541)',
680
+ );
681
+ }
682
+
683
+ /** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
684
+ const carryArt = (): void => {
685
+ if (att.path !== undefined) out.path = att.path;
686
+ // `buildRigRegion` writes `path` when the image basename differs from the
687
+ // placeholder, so naming the image after `path ?? placeholder` reproduces
688
+ // the same `path` decision AND the same atlas region name.
689
+ if (opts.art === 'loose') out.image = `${att.path === undefined ? placeholder : String(att.path)}.png`;
690
+ if (att.width !== undefined) out.width = att.width;
691
+ if (att.height !== undefined) out.height = att.height;
692
+ };
693
+
694
+ /**
695
+ * The vertex array, as one of the two encodings `readVertices` decides between.
696
+ *
697
+ * Inverts `buildVertexGeometry` / `encodeNamedWeights`: the run is unweighted
698
+ * when it is exactly as long as the coordinate count the attachment declares,
699
+ * and a weight run otherwise. That length comparison is the parser's own.
700
+ */
701
+ const geometry = (declaredPairs: number | undefined): void => {
702
+ const vertices = numbers(att.vertices);
703
+ if (vertices === undefined) return;
704
+ if (declaredPairs !== undefined && vertices.length === declaredPairs * 2) out.vertices = vertices;
705
+ else out.weights = decodeWeights(vertices, boneNames);
706
+ };
707
+
708
+ const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
709
+
710
+ if (type === 'region') {
711
+ carryArt();
712
+ for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
713
+ if (att[field] !== undefined) out[field] = att[field];
714
+ }
715
+ } else if (type === 'mesh') {
716
+ out.type = 'mesh';
717
+ carryArt();
718
+ out.uvs = att.uvs;
719
+ out.triangles = att.triangles;
720
+ geometry(Array.isArray(att.uvs) ? att.uvs.length / 2 : undefined);
721
+ // Carried rather than re-derived: `authoredHullAndEdges` takes an authored
722
+ // pair as written and cross-checks `hull` against the triangles, so stating
723
+ // both keeps the emitted arrays identical instead of equal-by-derivation.
724
+ if (att.hull !== undefined) out.hull = att.hull;
725
+ if (att.edges !== undefined) out.edges = att.edges;
726
+ if (att.color !== undefined) out.color = att.color;
727
+ } else if (type === 'boundingbox' || type === 'clipping') {
728
+ out.type = type;
729
+ out.vertexCount = att.vertexCount;
730
+ geometry(vertexCount);
731
+ if (att.color !== undefined) out.color = att.color;
732
+ if (type === 'clipping') {
733
+ for (const field of ['end', 'convex', 'inverse']) if (att[field] !== undefined) out[field] = att[field];
734
+ }
735
+ } else if (type === 'path') {
736
+ out.type = 'path';
737
+ out.vertexCount = att.vertexCount;
738
+ geometry(vertexCount);
739
+ for (const field of ['closed', 'constantSpeed', 'color']) if (att[field] !== undefined) out[field] = att[field];
740
+ if (att.lengths !== undefined) {
741
+ note(
742
+ 'lossy',
743
+ 'PATH_LENGTHS',
744
+ at.where,
745
+ 'the source states `lengths`; the rig spec refuses an authored one and rigc RE-MEASURES it as ' +
746
+ '`PathConstraint` does (issue #560, `pathCurveLengths`). Dropping it is correct: the field is the ' +
747
+ "runtime's own four-sample forward difference, not an arc length, and a transcribed one would freeze " +
748
+ 'whatever produced the source',
749
+ );
750
+ }
751
+ } else {
752
+ note(
753
+ 'blocker',
754
+ `ATTACHMENT_${type.toUpperCase()}`,
755
+ at.where,
756
+ `attachment type ${JSON.stringify(type)} is in the Spine 4.3 format and rigc does not emit it (it emits ` +
757
+ `${ATTACHMENT_TYPES.join(', ')}; point and linkedmesh are deferred — docs/SPEC_COVERAGE.md part 1-6 says ` +
758
+ 'what each would carry). The rebuild will not have this attachment',
759
+ );
760
+ return out;
761
+ }
762
+
763
+ if (att.sequence !== undefined) {
764
+ note(
765
+ 'blocker',
766
+ 'ATTACHMENT_SEQUENCE',
767
+ at.where,
768
+ 'the attachment carries a `sequence` block (a numbered image series), which the rig spec cannot say; ' +
769
+ 'the rebuild draws the single region this attachment names',
770
+ );
771
+ }
772
+ return out;
773
+ }
774
+
775
+ /** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
776
+ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, note: Note): JsonObject {
777
+ const tracks: JsonObject[] = [];
778
+ let maxT = 0;
779
+ const seeT = (t: number): void => {
780
+ if (t > maxT) maxT = t;
781
+ };
782
+ const timeOf = (key: JsonObject): number => {
783
+ const t = typeof key.time === 'number' ? key.time : 0;
784
+ seeT(t);
785
+ return t;
786
+ };
787
+
788
+ /**
789
+ * A key's easing, as the motion spec spells it.
790
+ *
791
+ * Inverts `rawCurve`: `"stepped"` is a named easing the compiler passes
792
+ * through, and an array is the absolute (time, value) control points, four per
793
+ * channel, which `curve` takes verbatim. A key with neither is linear.
794
+ */
795
+ const easing = (key: JsonObject, out: JsonObject): void => {
796
+ if (key.curve === 'stepped') out.ease = 'stepped';
797
+ else if (key.curve !== undefined) out.curve = key.curve;
798
+ };
799
+
800
+ /**
801
+ * A value track of any of the four families. Inverts `compileValueTrack`.
802
+ *
803
+ * ⚠️ `compileValueTrack` never omits a channel, so a rigc-built key states all
804
+ * of them and this reads them straight back. An EDITOR omits a channel that
805
+ * equals the parser's default, and the spec's `v` is positional — so an
806
+ * omission is filled at that channel's default, which is the value the runtime
807
+ * reads there. Reported once per track, because it is a restatement rather
808
+ * than a copy and a reader should know which.
809
+ */
810
+ const valueTrack = (target: JsonObject, property: string, keys: readonly unknown[], shape: TrackShape, where: string): void => {
811
+ const fields = shape.map(([field]) => field);
812
+ const out: JsonObject[] = [];
813
+ let restated = 0;
814
+ for (const raw of keys) {
815
+ const key = obj(raw);
816
+ const entry: JsonObject = { t: timeOf(key) };
817
+ // The zero-field branch: `reset` IS the event, so the key carries no value
818
+ // and the spec spells that `null`.
819
+ entry.v =
820
+ shape.length === 0
821
+ ? null
822
+ : shape.map(([field, dflt]) => {
823
+ if (key[field] !== undefined) return key[field];
824
+ restated++;
825
+ // A string default names another field of THIS key (`mixY` ->
826
+ // `mixX`); when that one is absent too the chain ends at 1, which
827
+ // is what `ConstraintChannel.dflt` holds for both.
828
+ if (typeof dflt !== 'string') return dflt;
829
+ return key[dflt] === undefined ? 1 : key[dflt];
830
+ });
831
+ easing(key, entry);
832
+ for (const field of Object.keys(key)) {
833
+ if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
834
+ note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
835
+ }
836
+ out.push(entry);
837
+ }
838
+ if (restated > 0) {
839
+ note(
840
+ 'lossy',
841
+ 'TIMELINE_KEY_RESTATED',
842
+ where,
843
+ `${restated} channel value(s) the source omits are written out at the parser's default (${shape
844
+ .map(([field, dflt]) => `${field}=${String(dflt)}`)
845
+ .join(', ')}), because the motion spec's \`v\` is positional — the same values the runtime reads`,
846
+ );
847
+ }
848
+ tracks.push({ ...target, property, keys: out });
849
+ };
850
+
851
+ /**
852
+ * One family of constraint timelines: `<family>.<constraint>.<timeline>`.
853
+ *
854
+ * ⭐ All three tables are now COMPLETE against `SkeletonJson`'s switch for
855
+ * their group, so the blocker below no longer fires on anything the runtime
856
+ * plays — it is reachable only for a timeline name the parser itself falls
857
+ * through (`:1094` for physics, and neither the path nor the slider switch
858
+ * has a default either). It is kept rather than deleted because `ingest`'s
859
+ * contract is byte identity and not equivalence: a name nothing reads is
860
+ * still a name the rebuild does not write. The alternative — demoting it to
861
+ * `lossy`, on the argument that the rebuilt skeleton plays identically — is
862
+ * a decision about all three families and is not made here.
863
+ */
864
+ const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
865
+ for (const [name, timelines] of objEntries(anim[group])) {
866
+ for (const [property, keys] of arrEntries(timelines)) {
867
+ const shape = shapes[property];
868
+ const where = `animation "${animName}" ${group} "${name}" ${property}`;
869
+ if (shape === undefined) {
870
+ note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
871
+ continue;
872
+ }
873
+ valueTrack({ [group]: name }, property, keys, shape, where);
874
+ }
875
+ }
876
+ };
877
+
878
+ for (const [bone, timelines] of objEntries(anim.bones)) {
879
+ for (const [property, keys] of arrEntries(timelines)) {
880
+ const shape = BONE_TRACKS[property];
881
+ const where = `animation "${animName}" bone "${bone}" ${property}`;
882
+ if (shape === undefined) {
883
+ note('blocker', 'BONE_TIMELINE', where, `timeline "${property}" is not in the motion spec`);
884
+ continue;
885
+ }
886
+ valueTrack({ bone }, property, keys, shape, where);
887
+ }
888
+ }
889
+
890
+ for (const [slot, timelines] of objEntries(anim.slots)) {
891
+ for (const [property, keys] of arrEntries(timelines)) {
892
+ const where = `animation "${animName}" slot "${slot}" ${property}`;
893
+ if (property === 'attachment') {
894
+ // Inverts `compileTrack`'s attachment branch: `{time, name}`, where a
895
+ // null name is "show nothing". Attachment keys are stepped by nature and
896
+ // carry no curve at all.
897
+ tracks.push({
898
+ slot,
899
+ property: 'attachment',
900
+ keys: keys.map((raw) => {
901
+ const key = obj(raw);
902
+ return { t: timeOf(key), v: key.name === undefined ? null : key.name };
903
+ }),
904
+ });
905
+ } else if (property === 'rgba') {
906
+ // Inverts `compileTrack`'s rgba branch, whose key is `{time, color}`.
907
+ tracks.push({
908
+ slot,
909
+ property: 'rgba',
910
+ keys: keys.map((raw) => {
911
+ const key = obj(raw);
912
+ const entry: JsonObject = { t: timeOf(key), v: hexToRgba(String(key.color)) };
913
+ easing(key, entry);
914
+ return entry;
915
+ }),
916
+ });
917
+ } else {
918
+ note(
919
+ 'blocker',
920
+ 'SLOT_TIMELINE',
921
+ where,
922
+ `timeline "${property}" is in the format and the motion spec has no track for it — a slot track is ` +
923
+ `${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here`,
924
+ );
925
+ }
926
+ }
927
+ }
928
+
929
+ family('path', PATH_TRACKS);
930
+ family('physics', PHYSICS_TRACKS);
931
+ family('slider', SLIDER_TRACKS);
932
+
933
+ const ik = constraintGroup('ik', animName, anim, root, IK_KEY_DEFAULTS, seeT, note);
934
+ const transform = constraintGroup('transform', animName, anim, root, TRANSFORM_KEY_DEFAULTS, seeT, note);
935
+
936
+ // deform — `attachments.<skin>.<slot>.<attachment>.<timeline>`.
937
+ // Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
938
+ // and whose `offset` is omitted at 0 (the parser's default).
939
+ const deform: JsonObject[] = [];
940
+ for (const [skinName, perSkin] of objEntries(anim.attachments)) {
941
+ for (const [slot, perSlot] of objEntries(perSkin)) {
942
+ for (const [attachment, timelines] of objEntries(perSlot)) {
943
+ for (const [property, keys] of arrEntries(timelines)) {
944
+ const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
945
+ if (property !== 'deform') {
946
+ note('blocker', 'ATTACHMENT_TIMELINE', where, `timeline "${property}" (the motion spec carries \`deform\` only)`);
947
+ continue;
948
+ }
949
+ const entry: JsonObject = {
950
+ slot,
951
+ attachment,
952
+ keys: keys.map((raw) => {
953
+ const key = obj(raw);
954
+ const out: JsonObject = { t: timeOf(key) };
955
+ if (key.offset !== undefined) out.offset = key.offset;
956
+ if (key.vertices !== undefined) out.vertices = key.vertices;
957
+ easing(key, out);
958
+ return out;
959
+ }),
960
+ };
961
+ // `skin` is absent for the default skin, which is the spec's own
962
+ // spelling (`MotionDeformTrack.skin`: absent = "default").
963
+ if (skinName !== 'default') entry.skin = skinName;
964
+ deform.push(entry);
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ // drawOrder — inverts `compileDrawOrder`. A key with no `offsets` restores the
971
+ // setup order; that is the parser's own encoding and the spec spells it the
972
+ // same way, so an absent array stays absent.
973
+ let drawOrder: JsonObject[] | undefined;
974
+ if (Array.isArray(anim.drawOrder)) {
975
+ drawOrder = anim.drawOrder.map((raw) => {
976
+ const key = obj(raw);
977
+ const out: JsonObject = { t: timeOf(key) };
978
+ if (Array.isArray(key.offsets)) {
979
+ out.offsets = key.offsets.map((o) => ({ slot: obj(o).slot, offset: obj(o).offset }));
980
+ }
981
+ return out;
982
+ });
983
+ }
984
+
985
+ // events — inverts `compileEvents`. The payload fields are written only where
986
+ // the firing overrides the declared event's own, which is what the file holds.
987
+ let events: JsonObject[] | undefined;
988
+ if (Array.isArray(anim.events)) {
989
+ events = anim.events.map((raw) => {
990
+ const key = obj(raw);
991
+ const out: JsonObject = { t: timeOf(key), name: key.name };
992
+ for (const field of ['int', 'float', 'string', 'volume', 'balance']) {
993
+ if (key[field] !== undefined) out[field] = key[field];
994
+ }
995
+ return out;
996
+ });
997
+ }
998
+
999
+ for (const group of Object.keys(anim)) {
1000
+ if (ANIMATION_GROUPS.includes(group)) continue;
1001
+ note('blocker', 'ANIMATION_GROUP', `animation "${animName}"`, `group "${group}" is not one readAnimation reads, so it is dropped`);
1002
+ }
1003
+
1004
+ // 🚨 There is no duration in skeleton JSON. The largest key time is the only
1005
+ // derivable answer and it is what a runtime plays to; it is WRONG for an
1006
+ // animation that holds its last pose past its last key, and nothing in the
1007
+ // file distinguishes the two. Recorded per animation rather than hidden.
1008
+ note(
1009
+ 'judgement',
1010
+ 'DURATION',
1011
+ `animation "${animName}"`,
1012
+ `skeleton JSON carries no duration; the largest key time (${maxT}) is used, which is what a runtime plays to. ` +
1013
+ 'An animation meant to hold past its last key needs the real number stated by hand',
1014
+ );
1015
+
1016
+ const out: JsonObject = { duration: maxT, tracks };
1017
+ if (ik.length) out.ik = ik;
1018
+ if (transform.length) out.transform = transform;
1019
+ if (deform.length) out.deform = deform;
1020
+ if (drawOrder !== undefined) out.drawOrder = drawOrder;
1021
+ if (events !== undefined) out.events = events;
1022
+ return out;
1023
+ }
1024
+
1025
+ /**
1026
+ * `ik` / `transform` — one unnamed timeline per constraint.
1027
+ *
1028
+ * Inverts `compileConstraintTrack`, and this is the one inversion that has to
1029
+ * RESTATE rather than copy. Two reasons, and neither invents a value:
1030
+ *
1031
+ * 1. **The uniform field set.** Every field of these keys is optional with a
1032
+ * per-key default, so `compileConstraintTrack` refuses a track whose keys do
1033
+ * not all name the same fields — *"state it on every key or on none"*. An
1034
+ * export does not obey that: it omits a field wherever it equals the default.
1035
+ * So a field ANY key states is written on EVERY key, at the value the parser
1036
+ * would have read there. Identical semantics, larger file.
1037
+ * 2. 🚨 **`rigFlags`.** `compileConstraintTrack` stamps the rig constraint's
1038
+ * non-default `bendPositive`/`compress`/`stretch` onto a key that omits one
1039
+ * (issue #273). On rigc's own output that is self-consistent — rigc already
1040
+ * wrote the flag on every key, so it is read back as stated. On a FOREIGN
1041
+ * export it would change what plays: the export's omission means the per-key
1042
+ * default, and the stamp would substitute the constraint's setup value. So a
1043
+ * flag the constraint declares non-default is written on every key at the
1044
+ * PARSER default, which is what the export actually plays.
1045
+ */
1046
+ function constraintGroup(
1047
+ group: 'ik' | 'transform',
1048
+ animName: string,
1049
+ anim: JsonObject,
1050
+ root: JsonObject,
1051
+ defaults: Record<string, number | boolean | string>,
1052
+ seeT: (t: number) => void,
1053
+ note: Note,
1054
+ ): JsonObject[] {
1055
+ const fields = Object.keys(defaults);
1056
+ const out: JsonObject[] = [];
1057
+ for (const [name, keys] of arrEntries(anim[group])) {
1058
+ const where = `animation "${animName}" ${group} "${name}"`;
1059
+ const stated = new Set<string>();
1060
+ for (const raw of keys) {
1061
+ const key = obj(raw);
1062
+ for (const field of fields) if (key[field] !== undefined) stated.add(field);
1063
+ }
1064
+ if (group === 'ik') {
1065
+ const constraint = arr(root.constraints)
1066
+ .map(obj)
1067
+ .find((c) => nameOf(c) === name && c.type === 'ik');
1068
+ for (const flag of IK_FLAGS) {
1069
+ if (constraint !== undefined && constraint[flag] !== undefined && constraint[flag] !== IK_KEY_DEFAULTS[flag]) {
1070
+ stated.add(flag);
1071
+ }
1072
+ }
1073
+ }
1074
+ if (stated.size > 0 && stated.size < fields.length) {
1075
+ note(
1076
+ 'lossy',
1077
+ 'CONSTRAINT_KEY_RESTATED',
1078
+ where,
1079
+ `${fields.length - stated.size} field(s) the source omits are restated at the parser's default on every key, ` +
1080
+ 'because the motion spec requires one field set per track — the same values the runtime reads, spelled out',
1081
+ );
1082
+ }
1083
+ const ks = keys.map((raw) => {
1084
+ const key = obj(raw);
1085
+ const entry: JsonObject = { t: typeof key.time === 'number' ? key.time : 0 };
1086
+ seeT(entry.t as number);
1087
+ for (const field of fields) {
1088
+ if (!stated.has(field)) continue;
1089
+ if (key[field] !== undefined) entry[field] = key[field];
1090
+ else {
1091
+ const dflt = defaults[field];
1092
+ // `mixY`'s default is the same key's own `mixX`, spelled as that field
1093
+ // name in the table above.
1094
+ entry[field] = typeof dflt === 'string' ? (key[dflt] !== undefined ? key[dflt] : defaults[dflt]) : dflt;
1095
+ }
1096
+ }
1097
+ if (key.curve === 'stepped') entry.ease = 'stepped';
1098
+ else if (key.curve !== undefined) entry.curve = key.curve;
1099
+ for (const field of Object.keys(key)) {
1100
+ if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
1101
+ note('blocker', `${group.toUpperCase()}_KEY_FIELD`, where, `key field "${field}" is not part of this timeline's shape`);
1102
+ }
1103
+ return entry;
1104
+ });
1105
+ out.push({ constraint: name, keys: ks });
1106
+ }
1107
+ return out;
1108
+ }
1109
+
1110
+ /**
1111
+ * The provenance sentence both specs carry (INGEST §2.4's rule).
1112
+ *
1113
+ * ⚠️ A decompiled spec is indistinguishable from an authored one by inspection,
1114
+ * and every gate in this tree will call it green — because it IS green. No gate
1115
+ * catches a missing note, which is exactly why `ingest` writes one itself rather
1116
+ * than leaving it to the caller.
1117
+ *
1118
+ * 🔒 No timestamp, and that is a contract rather than a style: `A18` compares two
1119
+ * independent compiles byte for byte, and a dated note in a spec would break the
1120
+ * first rebuild from it.
1121
+ */
1122
+ function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion'): string {
1123
+ const head =
1124
+ `DECOMPILED from ${opts.source} by \`rigc ingest\` ${opts.version}. Every number here was read out of that ` +
1125
+ 'skeleton; nothing was authored, so this file says what the object IS and nothing about why.';
1126
+ if (which === 'rig') {
1127
+ return (
1128
+ `${head} \`invariants\` is deliberately absent — a skeleton declares none, and an assertion with nothing to ` +
1129
+ 'measure must SKIP rather than pass.'
1130
+ );
1131
+ }
1132
+ return (
1133
+ `${head} Every curve is a raw \`curve\` array — the absolute (time, value) control points verbatim — because an ` +
1134
+ 'export carries a different bezier per key per channel and no named easing can say that. Each `duration` is the ' +
1135
+ 'largest key time in its animation, which is the only figure skeleton JSON supports.'
1136
+ );
1137
+ }