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