spine-rigc 0.26.0 → 0.28.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/docs/INGEST.md CHANGED
@@ -237,7 +237,9 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
237
237
  `diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
238
238
  blocks that report and gate nothing: the `(reported)` measures beside `attachments`
239
239
  and `animations`, and the `skeleton` header block at the top, which measures the stage
240
- (issue #578). Both sides may be foreign; the interesting pairing during ingest is
240
+ (issue #578). A ninth group of six joins them when something has paired the two sides'
241
+ animations — `--as <candidate>=<reference>`, or one animation each side, which pairs by
242
+ position (§1.3.1). Both sides may be foreign; the interesting pairing during ingest is
241
243
  **your transcription against the export it came from**:
242
244
 
243
245
  ```bash
@@ -326,6 +328,44 @@ attachment types and bone-binding shapes by draw-order position. That is how you
326
328
  *"the same rig with a different vocabulary"* from *"a different rig"*, and §4.2 is the
327
329
  recipe built on it.
328
330
 
331
+ #### 1.3.1 Animations differ by name too — `--as`
332
+
333
+ `bones` and `slots` are matched name-agnostically by their own shape. Animations have
334
+ none: the candidate's `take01` and the reference's `arcs` are the same shot only
335
+ because somebody says they are. Two things say it —
336
+
337
+ ```bash
338
+ rigc diff work/t6/skeleton.json examples/6-arcs/export/6-arcs-pro.json --as take01=arcs
339
+ ```
340
+
341
+ — and, with no flag, **one animation each side**, which pairs by position because there
342
+ is exactly one reading of which shot is which. `--as` is repeatable, one pair each, and
343
+ the candidate's name goes on the left, as it does in `bonedist`'s correspondence file.
344
+
345
+ With the pairing in hand the `animations` section reports two figures like the other
346
+ two sections, the second over the paired shots — `duration`, `timeline_kinds`,
347
+ `key_counts`, `curve_kinds`, `draw_order`, `deform` — and the heading says which pairing
348
+ it used:
349
+
350
+ ```
351
+ animations mean 0.222 over 9 measures
352
+ 1.000 count 1/1 how many animations
353
+ 0.000 names 0/2 the animation names
354
+ …
355
+ animations (name-agnostic) mean 1.000 over 6 measures — the same two skeletons compared with names thrown away, paired by position: take01=arcs, the one animation each side carries
356
+ ```
357
+
358
+ Read that pair exactly as you read `bones`'s: **1.000 beside `names` 0.000** says the
359
+ shot is right and its name is yours. ⛔ `names` never moves into the second block, and
360
+ with two shots on each side and no `--as`, the block is **absent** rather than paired by
361
+ declaration order — a candidate that declares its two shots the other way round would
362
+ then read 0.000 across it and the report would be calling a guess a measurement.
363
+
364
+ ⚠️ An `--as` naming an animation a side does not have is **refused** with what that side
365
+ does have, and so is one that pairs the same animation twice. Neither is dropped
366
+ quietly: a typo that measured less than you asked for is a report about a pairing you
367
+ did not state.
368
+
329
369
  ### 1.4 `check` — the instrument that does see coordinates
330
370
 
331
371
  ```bash
@@ -476,6 +516,19 @@ a default the source left to the format and the rebuild writes out). A blocker e
476
516
  non-zero and still writes both files. **Every code it can print has a row at the end
477
517
  of this section**, with its gutter, its effect on the exit code and what to do.
478
518
 
519
+ ⛔ **And it reads one generation.** Spine data is locked to the generation that
520
+ exported it, and a mismatch is silent rather than loud: 4.3 takes constraints from the
521
+ top-level `constraints` array alone, so a 4.0–4.2 file's `ik`/`transform`/`path`/
522
+ `physics` arrays load as nothing at all — 1,302 shipped skeletons parsed on a 4.3
523
+ runtime and loaded 0 of 8,672 constraints
524
+ ([#706](https://github.com/firejune/rigc/issues/706) row 1). So `ingest` reads
525
+ `skeleton.spine` before it reads a field of the file, and a file from another
526
+ generation is a blocker naming that generation and counting, **on that file**, what a
527
+ 4.3 reader loses by it. Reading such a file with *that generation's own* defaults is a
528
+ different job — #706's item 2, a per-generation table extracted by machine from each
529
+ runtime's `SkeletonJson` — and it is not in this tool, which is why the finding points
530
+ at the policy rather than implying the file was read.
531
+
479
532
  **Two values are not in a skeleton**, so `ingest` asks rather than guesses:
480
533
 
481
534
  - **the stage** (`skeleton.width`/`height`) — `--stage x,y,w,h` is how you supply one
@@ -540,6 +593,7 @@ is the one failure a comparison of two sets cannot show you.
540
593
  | --- | --- | --- | --- | --- |
541
594
  | `ANIMATION_GROUP` | `BLOCK` | 1 | the animation carries a group the motion spec has no home for. The detail names the ten it does carry. `drawOrderFolder` is the group to know about: the runtime reads it and builds a timeline from it, and no export in this corpus carries one | transcribe that group by hand (§2), or accept that the rebuild does not carry it |
542
595
  | `ATTACHMENT_<TYPE>` | `BLOCK` | 1 | an attachment of a type rigc does not emit; the code is composed from the type, so on the one type left it reads `ATTACHMENT_POINT`. rigc emits region, mesh, linkedmesh, boundingbox, clipping and path — `linkedmesh` since [#691](https://github.com/firejune/rigc/issues/691), and `point` is the remaining deferred type | the rebuild will not have that attachment at all. `docs/SPEC_COVERAGE.md` part 1-6 says what a deferred type would carry |
596
+ | `ATTACHMENT_LINK_GEOMETRY` | `LOSS` | 0 | a **linked mesh** that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by nothing at all and the attachment draws the geometry its `source` names; the rig spec has no home for them either, because `build` refuses geometry on a link by name. The detail lists the keys and the source. Until [#710](https://github.com/firejune/rigc/issues/710) the rebuild dropped them with no line at all, so an `ingest` that normalised somebody's file said nothing about it | nothing. The rebuild is the mesh the runtime was already drawing — and if those keys were the geometry you meant, take `source` off and author it as a mesh of its own. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` is the same fact at the gate |
543
597
  | `ATTACHMENT_NAME` | `LOSS` | 0 | the attachment states a `name` and only **one** skin fills the placeholder, so rigc writes none — it composes `<skin>/<placeholder>` exactly where a placeholder is contested | nothing, unless something downstream looks that attachment up by the name the source gave it |
544
598
  | `ATTACHMENT_SEQUENCE` | `BLOCK` | 1 | the attachment carries a `sequence` block — a numbered image series — which the rig spec cannot say | the rebuild draws the single region the attachment names; the frames have to be driven some other way |
545
599
  | `ATTACHMENT_TIMELINE` | `BLOCK` | 1 | an attachment timeline other than `deform`, which is the only one the motion spec carries | transcribe it, or accept that the rebuild does not play it |
@@ -549,9 +603,11 @@ is the one failure a comparison of two sets cannot show you.
549
603
  | `CONSTRAINT_KEY_RESTATED` | `LOSS` | 0 | an `ik` or `transform` track whose keys do not all state the same fields. The motion spec takes one field set per track, so a field **any** key states is written on **every** key at the value the parser would have read there | nothing. Same values, larger file — the rebuild plays what the source plays |
550
604
  | `CONSTRAINT_TYPE` | `BLOCK` | 1 | a constraint whose `type` is none rigc knows, so the whole constraint is dropped rather than approximated | the rebuild has no such constraint; check the spelling before assuming the type is unsupported |
551
605
  | `DURATION` | `JUDGE` | 0 | skeleton JSON has no duration field at all. The largest key time is used, which is what a runtime plays to — and wrong for an animation that holds its last pose past its last key | if you know the real number, edit `duration` in the motion spec. It costs nothing: the declared duration is checked against the compiled keys |
606
+ | `GENERATION_UNKNOWN` | `BLOCK` | 1 | `skeleton.spine` names no generation rigc knows, or the header states none at all. A version is read as its LEADING `major.minor` token — a down-export writes `4.0-from-4.1.24`, which is 4.0 data from a 4.1 editor — and it is never rounded to the nearest generation: a catalog that rounded handed 19 skeletons labelled `3.8.99` a 4.2 runtime and every one posed as NaN ([#706](https://github.com/firejune/rigc/issues/706) row 7) | check the string against the file you were handed. A real generation rigc does not list belongs on #706 item 1, with the string beside it |
607
+ | `GENERATION_UNSUPPORTED` | `BLOCK` | 1 | the file is Spine data from another generation and this reader reads 4.3. The detail names the generation, the string it was read from, and what a 4.3 reader loses on **this** file: constraints parked in the top-level `ik` / `transform` / `path` / `physics` / `slider` arrays 4.3 folded into `constraints` and this reader never opens (row 1), bones carrying 4.2's `transform` where 4.3 spells `inherit` (row 6), and physics constraints omitting `inertia` / `damping`, whose default is not the same number in 4.2 as in 4.3 (row 4) | re-export the file as 4.3 from an editor of its own generation, or transcribe it by hand (§2). Reading it with **that generation's** defaults is #706 item 2 and is not in this tool |
552
608
  | `HEADER_BOOKKEEPING` | `LOSS` | 0 | a header field the editor writes and the rig spec has no home for — `hash`, `audio`. Dropped, and nothing reads it back | nothing. It is one of the three differences §2.3 measures on every editor export |
553
609
  | `HEADER_ORIGIN` | `LOSS` | 0 | the source declares an extent and omits `x`/`y`. Inside a declared extent an omitted origin **is** 0, so the spec states it — and the rebuild then spells two fields the source did not | nothing. Same box, different bytes — which is why byte identity is not the claim for an export that takes this branch |
554
- | `HEADER_REDERIVED` | `LOSS` | 0 | `skeleton.spine`: the rebuild writes the version of the runtime rigc links. The line says whether that is the same string the source states | nothing — but read the line: a 4.2 export rebuilds as 4.3 in that one field |
610
+ | `HEADER_REDERIVED` | `LOSS` | 0 | `skeleton.spine`: the rebuild writes the version of the runtime rigc links. The line says whether that is the same string the source states | nothing — but read the line: a 4.2 export rebuilds as 4.3 in that one field, and a source from another generation raises `GENERATION_UNSUPPORTED` beside it, which is the blocker about the DATA rather than about the string |
555
611
  | `IK_KEY_FIELD` | `BLOCK` | 1 | a key field on an `ik` timeline that is not part of its shape | check the spelling; an unknown field is dropped from the rebuilt track |
556
612
  | `NO_STAGE` | `BLOCK` `JUDGE` | 1 | the skeleton declares no stage. It is a blocker with no `--stage`, and a **judgement** — exit 0 — when `--stage x,y,w,h` supplies one, because nothing measured the box you gave it | supply the box from the project the file came from. It cannot be derived: posing the rig gives the animated extent, which is a different number |
557
613
  | `PATH_LENGTHS` | `LOSS` | 0 | the source states a path attachment's `lengths` and rigc re-measures it as `PathConstraint` does | nothing. Dropping it is the correct reading: the field is the runtime's own four-sample forward difference, not an arc length |
@@ -736,6 +736,7 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
736
736
  | `A11_NO_CLIPPING_ATTACHMENTS` | **renderer-profile** | clipping attachments — "the renderer skips them silently" |
737
737
  | `A12_NO_DARK_COLOR` | **renderer-profile** | slot `dark`, `rgba2`/`rgb2` timelines — "parsed, then ignored". ⚠️ rigc **emits** the first two; a renderer that drops a construct is what a profile is for, not a reason not to emit it |
738
738
  | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | validity | a slot `dark` the parser drops or reads as NaN, an `rgba2` timeline on a slot with no dark colour to pose, or a key whose posed light/dark is not what it states |
739
+ | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | validity | a linked mesh, in either spelling, that also states `uvs`, `triangles`, `vertices`, `hull` or `edges` — keys the `source` branch returns before reading, so the file says one mesh and every runtime draws its source's |
739
740
  | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
740
741
  | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
741
742
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/diff.ts CHANGED
@@ -46,6 +46,34 @@
46
46
  * wrong; name-agnostic low alone is impossible, since a wrong shape cannot
47
47
  * have right names.
48
48
  *
49
+ * ⭐ `animations` carries the same second comparison, and it arrived last
50
+ * because it needs something the other two do not: a PAIRING. A bone is
51
+ * paired with a bone by its depth and its child count, which the file
52
+ * states; two animations have no such shape to be matched on, so the
53
+ * candidate's `take01` and the reference's `arcs` are the same shot only
54
+ * because somebody says they are. Until that was said, every animation
55
+ * measure was keyed on the name — and a candidate that followed a brief
56
+ * withholding it read `count` 1/1 and **0.000 on all eight measures below**,
57
+ * on a shot with the same duration, the same timeline families and the same
58
+ * key counts (issue #720). That is the *gate that cannot be passed* shape on
59
+ * the measuring instrument rather than on the gate.
60
+ *
61
+ * Two things say it, and nothing else does. `--as <candidate>=<reference>`
62
+ * pairs them outright; failing that, **one animation each side** pairs by
63
+ * position, because there is exactly one reading of which shot is which and
64
+ * no name is consulted to reach it. Anything else — two against two, three
65
+ * against one — has several readings, so the block is ABSENT rather than
66
+ * guessed at, which is what `DiffSection.nameAgnostic` means by *"should say
67
+ * so by having none"*. ⚠️ Guessing there is the failure this file is built
68
+ * against: pairing two-against-two by position would score a candidate whose
69
+ * two shots are declared in the other order 0.000 across the block and call
70
+ * it a measurement.
71
+ *
72
+ * 🔒 `names` stays in the name-matched block alone, and that is the whole
73
+ * point of the split rather than an oversight: the pair is read as *agnostic
74
+ * 1.000 with `names` 0.000*, which says the shot is right and its name is the
75
+ * author's own.
76
+ *
49
77
  * 4. **A measure that cannot gate is not in the mean.** `section.reported`
50
78
  * carries the measures `docs/GATE.md`'s *What never gates* calls
51
79
  * unobservable by construction — *"could any reading of the frames have
@@ -121,6 +149,18 @@ export interface DiffAgnostic {
121
149
  /** Unweighted mean of the measures below. NOT a quality score either. */
122
150
  ratio: number;
123
151
  measures: DiffMeasure[];
152
+ /**
153
+ * How the two sides were put against each other, for a block that had to
154
+ * choose — `animations` alone today. Absent where the correspondence is the
155
+ * elements themselves and there was nothing to decide.
156
+ *
157
+ * ⚠️ It is data rather than a caption. A block whose figures depend on a
158
+ * pairing, printed without the pairing beside it, is a measurement of
159
+ * something the reader cannot name — and the two pairings say different
160
+ * things: `--as` is the caller's claim, position is this file's reading of a
161
+ * one-against-one roster.
162
+ */
163
+ pairedBy?: string;
124
164
  }
125
165
 
126
166
  /**
@@ -279,12 +319,21 @@ function sectionOf(
279
319
  measures: DiffMeasure[],
280
320
  nameAgnostic?: DiffMeasure[],
281
321
  reported?: DiffMeasure[],
322
+ pairedBy?: string,
282
323
  ): DiffSection {
283
324
  return {
284
325
  name,
285
326
  ratio: meanRatio(measures),
286
327
  measures,
287
- ...(nameAgnostic === undefined ? {} : { nameAgnostic: { ratio: meanRatio(nameAgnostic), measures: nameAgnostic } }),
328
+ ...(nameAgnostic === undefined
329
+ ? {}
330
+ : {
331
+ nameAgnostic: {
332
+ ratio: meanRatio(nameAgnostic),
333
+ measures: nameAgnostic,
334
+ ...(pairedBy === undefined ? {} : { pairedBy }),
335
+ },
336
+ }),
288
337
  ...(reported === undefined ? {} : { reported: { measures: reported } }),
289
338
  };
290
339
  }
@@ -905,11 +954,154 @@ function keyingTotals(f: AnimationFacts): KeyingTotals {
905
954
  return { keys: sum(f.keys), timelines: sum(f.kinds), seconds: sum(f.duration) };
906
955
  }
907
956
 
908
- function diffAnimations(c: Json, r: Json): DiffSection {
957
+ /** One animation on each side, said to be the same shot. */
958
+ export interface DiffAnimationPair {
959
+ candidate: string;
960
+ reference: string;
961
+ }
962
+
963
+ /**
964
+ * What a caller may tell `diffSkeletons` that neither file can say itself.
965
+ *
966
+ * Only the animation pairing today, and it is an INPUT in the sense
967
+ * `bonedist`'s correspondence file is one: two skeletons cannot derive which of
968
+ * their shots are the same shot, so a value worked out here would be a guess
969
+ * reported as a measurement.
970
+ */
971
+ export interface DiffOptions {
972
+ /** `--as <candidate>=<reference>`, in the order the caller stated them. */
973
+ animationPairs?: readonly DiffAnimationPair[];
974
+ }
975
+
976
+ /**
977
+ * The pairing used for `animations.agnostic.*`, or `null` when there is none.
978
+ *
979
+ * ⚠️ A stated pair naming an animation a side does not have is DROPPED and said
980
+ * so in `pairedBy`, rather than silently making the block narrower. `cmdDiff`
981
+ * refuses one by name before this is reached, so through the CLI the branch is
982
+ * unreachable; it exists because this module is exported and a caller of the
983
+ * API can state one.
984
+ */
985
+ function pairAnimations(
986
+ a: AnimationFacts,
987
+ b: AnimationFacts,
988
+ stated: readonly DiffAnimationPair[],
989
+ ): { pairs: DiffAnimationPair[]; pairedBy: string } | null {
990
+ const spell = (p: DiffAnimationPair): string => `${p.candidate}=${p.reference}`;
991
+ if (stated.length > 0) {
992
+ const usable = stated.filter((p) => a.duration.has(p.candidate) && b.duration.has(p.reference));
993
+ if (usable.length === 0) return null;
994
+ const dropped = stated.filter((p) => !usable.includes(p));
995
+ return {
996
+ pairs: [...usable],
997
+ pairedBy:
998
+ `paired by --as: ${usable.map(spell).join(', ')}` +
999
+ (dropped.length === 0 ? '' : `; ${dropped.map(spell).join(', ')} named an animation a side does not have and was dropped`),
1000
+ };
1001
+ }
1002
+ if (a.names.length === 1 && b.names.length === 1) {
1003
+ const pair = { candidate: a.names[0], reference: b.names[0] };
1004
+ return { pairs: [pair], pairedBy: `paired by position: ${spell(pair)}, the one animation each side carries` };
1005
+ }
1006
+ return null;
1007
+ }
1008
+
1009
+ /**
1010
+ * `f` restricted to the animations `label` names, with each one's name replaced
1011
+ * by the label — `#0` for the first pair, `#1` for the second.
1012
+ *
1013
+ * That substitution is the whole of what makes the block name-agnostic: the
1014
+ * measures below are the name-matched ones run again over facts whose keys are
1015
+ * positions in the pairing. Everything else about them — the tolerance, the
1016
+ * denominators, the histogram — is unchanged, which is what lets the two blocks
1017
+ * be read against each other.
1018
+ */
1019
+ function underLabels(f: AnimationFacts, label: ReadonlyMap<string, string>): AnimationFacts {
1020
+ const keyed = <T>(m: Map<string, T>): Map<string, T> => {
1021
+ const out = new Map<string, T>();
1022
+ for (const [anim, v] of m) {
1023
+ const to = label.get(anim);
1024
+ if (to !== undefined) out.set(to, v);
1025
+ }
1026
+ return out;
1027
+ };
1028
+ // `kinds`, `keys` and `curves` are keyed `<anim>|<rest>`, so only the head is
1029
+ // relabelled and the tail — the timeline's kind, the curve's shape — is what
1030
+ // the histogram then intersects on.
1031
+ const prefixed = (m: Map<string, number>): Map<string, number> => {
1032
+ const out = new Map<string, number>();
1033
+ for (const [k, v] of m) {
1034
+ const bar = k.indexOf('|');
1035
+ const to = label.get(k.slice(0, bar));
1036
+ if (to === undefined) continue;
1037
+ const id = `${to}${k.slice(bar)}`;
1038
+ out.set(id, (out.get(id) ?? 0) + v);
1039
+ }
1040
+ return out;
1041
+ };
1042
+ return {
1043
+ names: [...label.values()],
1044
+ duration: keyed(f.duration),
1045
+ kinds: prefixed(f.kinds),
1046
+ keys: prefixed(f.keys),
1047
+ curves: prefixed(f.curves),
1048
+ events: keyed(f.events),
1049
+ hasDrawOrder: keyed(f.hasDrawOrder),
1050
+ hasDeform: keyed(f.hasDeform),
1051
+ };
1052
+ }
1053
+
1054
+ /**
1055
+ * The six measures of `animations.agnostic.*`.
1056
+ *
1057
+ * ⛔ `names` is not among them, by construction — a block that threw the names
1058
+ * away cannot then compare them. ⛔ Neither is `count`, which `bones` and
1059
+ * `slots` do carry, and the difference is what the block is OVER: those two
1060
+ * compare whole rosters, so their agnostic half has the same subject as their
1061
+ * name-matched half and restates the count for a reader with one block open.
1062
+ * This one is over the PAIRS. A `count` in it would either restate the roster
1063
+ * figure — a different subject under the same heading — or count the pairs,
1064
+ * which measures the flag rather than the two rigs. The roster figure is
1065
+ * `animations.count`, and it is already name-free.
1066
+ */
1067
+ function agnosticAnimationMeasures(a: AnimationFacts, b: AnimationFacts, pairs: readonly DiffAnimationPair[]): DiffMeasure[] {
1068
+ const slot = (i: number): string => `#${i}`;
1069
+ const ca = underLabels(a, new Map(pairs.map((p, i) => [p.candidate, slot(i)])));
1070
+ const rb = underLabels(b, new Map(pairs.map((p, i) => [p.reference, slot(i)])));
1071
+ return [
1072
+ agreement(
1073
+ 'animations.agnostic.duration',
1074
+ 'each paired animation runs as long (last key time, within one frame)',
1075
+ ca.duration,
1076
+ rb.duration,
1077
+ (x, y) => Math.abs(x - y) <= FRAME,
1078
+ ),
1079
+ histogram('animations.agnostic.timeline_kinds', 'the same timelines exist in the paired animations', ca.kinds, rb.kinds),
1080
+ histogram('animations.agnostic.key_counts', 'those timelines carry as many keys', ca.keys, rb.keys),
1081
+ histogram('animations.agnostic.curve_kinds', 'as many linear / stepped / bezier keys', ca.curves, rb.curves),
1082
+ agreement(
1083
+ 'animations.agnostic.draw_order',
1084
+ 'a draw-order timeline is present or absent alike',
1085
+ ca.hasDrawOrder,
1086
+ rb.hasDrawOrder,
1087
+ (x, y) => x === y,
1088
+ ),
1089
+ agreement(
1090
+ 'animations.agnostic.deform',
1091
+ 'a deform timeline is present or absent alike',
1092
+ ca.hasDeform,
1093
+ rb.hasDeform,
1094
+ (x, y) => x === y,
1095
+ ),
1096
+ ];
1097
+ }
1098
+
1099
+ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPair[]): DiffSection {
909
1100
  const a = animationFacts(c);
910
1101
  const b = animationFacts(r);
911
1102
  const at = keyingTotals(a);
912
1103
  const bt = keyingTotals(b);
1104
+ const paired = pairAnimations(a, b, pairsStated);
913
1105
  const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
914
1106
  const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
915
1107
  return sectionOf('animations', [
@@ -929,7 +1121,14 @@ function diffAnimations(c: Json, r: Json): DiffSection {
929
1121
  agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
930
1122
  agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
931
1123
  ],
932
- undefined,
1124
+ // ── the same two skeletons' shots, paired rather than named (issue #720) ──
1125
+ //
1126
+ // Absent unless something pairs them — see `pairAnimations` and the header's
1127
+ // point 3. `undefined` and not `[]`: a block with no measures in it prints a
1128
+ // vacuous `mean 1.000 over 0 measures`, which is the false green this whole
1129
+ // file is built to refuse, and `movedAgnosticMeasures` cannot tell it from a
1130
+ // block that agreed about everything.
1131
+ paired === null ? undefined : agnosticAnimationMeasures(a, b, paired.pairs),
933
1132
  // ── reported (issue #20) ────────────────────────────────────────────────
934
1133
  //
935
1134
  // 🔍 What #20 asked and what was actually wrong. The issue proposed making key
@@ -981,7 +1180,8 @@ function diffAnimations(c: Json, r: Json): DiffSection {
981
1180
  `(${at.timelines} vs ${bt.timelines}), compared as min/max at ${RATE_PLACES} decimal places. Read beside ` +
982
1181
  '`key_density`: this one alone moving means the same keying spread over a different number of timelines.',
983
1182
  ),
984
- ]);
1183
+ ],
1184
+ paired?.pairedBy);
985
1185
  }
986
1186
 
987
1187
  function eventFacts(root: Json): Map<string, string> {
@@ -1168,11 +1368,19 @@ function orientation(root: Json): Record<string, number> {
1168
1368
  };
1169
1369
  }
1170
1370
 
1171
- export function diffSkeletons(candidate: unknown, reference: unknown): DiffReport {
1371
+ export function diffSkeletons(candidate: unknown, reference: unknown, options?: DiffOptions): DiffReport {
1172
1372
  const c = isObj(candidate) ? candidate : {};
1173
1373
  const r = isObj(reference) ? reference : {};
1374
+ const animationPairs = options?.animationPairs ?? [];
1174
1375
  return {
1175
- sections: [diffBones(c, r), diffSlots(c, r), diffAttachments(c, r), diffConstraints(c, r), diffAnimations(c, r), diffEvents(c, r)],
1376
+ sections: [
1377
+ diffBones(c, r),
1378
+ diffSlots(c, r),
1379
+ diffAttachments(c, r),
1380
+ diffConstraints(c, r),
1381
+ diffAnimations(c, r, animationPairs),
1382
+ diffEvents(c, r),
1383
+ ],
1176
1384
  header: diffHeader(c, r),
1177
1385
  candidate: orientation(c),
1178
1386
  reference: orientation(r),
@@ -1466,8 +1674,18 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
1466
1674
  );
1467
1675
  lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
1468
1676
  lines.push('');
1469
- // Wide enough for `<longest section> (name-agnostic)`, so that a section's two
1470
- // headings line their figures up under each other and read as a pair.
1677
+ // Wide enough for `bones (name-agnostic)` and `animations (reported)`, both
1678
+ // exactly 21, so that most of a section's headings line their figures up
1679
+ // under each other and read as a pair.
1680
+ //
1681
+ // ⚠️ Two headings are longer and push their own figure right instead:
1682
+ // `attachments (reported)`, which has done so since that block existed, and
1683
+ // `animations (name-agnostic)` (issue #720). Widening the column is the
1684
+ // obvious repair and it is the wrong one — it moves every heading line of
1685
+ // every report, and those lines are quoted verbatim in `docs/LADDER.md` and
1686
+ // in the landed run records under `bench/runs/`, which are sealed. A
1687
+ // cosmetic alignment is not worth a byte change in every transcript already
1688
+ // written, and the overflow is visible rather than silent.
1471
1689
  const head = (label: string, ratio: number, n: number): string =>
1472
1690
  ` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
1473
1691
  for (const section of report.sections) {
@@ -1478,7 +1696,9 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
1478
1696
  lines.push('');
1479
1697
  lines.push(
1480
1698
  `${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
1481
- ' — the same two skeletons compared with names thrown away',
1699
+ ' — the same two skeletons compared with names thrown away' +
1700
+ // The pairing is part of the figure, not decoration: see `DiffAgnostic.pairedBy`.
1701
+ (agnostic.pairedBy === undefined ? '' : `, ${agnostic.pairedBy}`),
1482
1702
  );
1483
1703
  lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
1484
1704
  }
@@ -1506,6 +1726,12 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
1506
1726
  lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
1507
1727
  lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
1508
1728
  lines.push('');
1729
+ lines.push(' `animations` carries the same pair, and only once something has PAIRED the two');
1730
+ lines.push(' sides\' shots: `--as <candidate>=<reference>`, or one animation each side, which');
1731
+ lines.push(' pairs by position. With neither there is no reading of which shot is which, so');
1732
+ lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
1733
+ lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
1734
+ lines.push('');
1509
1735
  lines.push(' `skeleton` is the file\'s own header block and reports two measures for the stage.');
1510
1736
  lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
1511
1737
  lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Which Spine data generation a skeleton's own version string names.
3
+ *
4
+ * ## Why this is one function and not a regex per caller
5
+ *
6
+ * Spine data is locked to the generation that exported it, and a mismatch fails
7
+ * **silently**: 4.3 takes constraints from the top-level `constraints` array
8
+ * alone, so a 4.0–4.2 file parses clean with none of them — measured across
9
+ * 1,302 shipped skeletons that loaded 0 of 8,672 constraints
10
+ * ([#706](https://github.com/firejune/rigc/issues/706) row 1). rigc is where
11
+ * that knowledge lives for the tools around it (#706 *Ownership*), and living
12
+ * in one place means one reader of `skeleton.spine`:
13
+ * `A16_SKELETON_VERSION_4_3` asks this function for its verdict, and `ingest`
14
+ * asks it before it reads a field of the file.
15
+ *
16
+ * ## What is here, and what is deliberately not
17
+ *
18
+ * This is #706's item **1**, and nothing else. Item **2** — the per-generation
19
+ * table of key renames, array shapes and `getValue(map, key, default)` defaults,
20
+ * extracted by machine from each runtime branch's `SkeletonJson` — is not here,
21
+ * and neither is item **4**'s conversion utility. So this module reads a string
22
+ * and names a generation. It does not read a file, apply another generation's
23
+ * defaults, or convert anything, and a caller that meets data from another
24
+ * generation has to say so out loud rather than read it anyway.
25
+ *
26
+ * ## The grammar, and the two rules in it
27
+ *
28
+ * A version string is `MAJOR.MINOR`, an optional chain of `-from-MAJOR.MINOR`,
29
+ * then an optional `.PATCH` and an optional `-SUFFIX` after that. The shipped
30
+ * strings #706 lists are `3.8.99`, `4.0.33`, `4.0-from-4.1.24`,
31
+ * `4.0-from-4.1-from-4.2.29`, `4.1-from-4.2.33`, `4.2.09-beta`, `4.2.43`,
32
+ * `4.3.26` and `4.3.75-beta`.
33
+ *
34
+ * 1. **The leading token is the generation.** `4.0-from-4.1.24` is 4.0 data
35
+ * written by a 4.1 editor — a *down-export* — and all 418 such-or-plain 4.0
36
+ * files and 101 4.1 files in #706's corpus load and render on the 4.0 / 4.1
37
+ * runtimes with 0 failures. Reading the trailing token would hand them the
38
+ * wrong runtime, which is row 7's defect with extra steps.
39
+ * 2. **Every token has to be a generation this module knows, and the chain has
40
+ * to ascend.** A down-export comes *from* a newer editor, so `4.0-from-4.1`
41
+ * is a down-export and `4.3-from-4.2` is not a version string this reader
42
+ * can account for. Both rules answer `null`, which is rule 3.
43
+ * 3. **Unknown is `null`, never the nearest.** A catalog builder gave 19
44
+ * skeletons labelled `3.8.99` the nearest runtime it had; they loaded, and
45
+ * posed 238 of 248 bones as NaN (#706 row 7). `null` is what a caller has to
46
+ * act on, and it is why this returns a union rather than a number to compare.
47
+ *
48
+ * ⭐ Rules 2 and 3 together are also what keeps `A16`'s accepted set **exactly**
49
+ * what its own regex accepted before this module existed: 4.3 is the highest
50
+ * generation here, nothing can ascend above it, so no `-from-` string is ever
51
+ * read as 4.3 and `A16` still accepts `4.3`, `4.3.<patch>` and
52
+ * `4.3.<patch>-<suffix>` and those alone. The cost is stated rather than hidden:
53
+ * a future editor down-exporting as `4.3-from-4.4.1` reads as `null` here and is
54
+ * refused by name until that generation is added — which is #706 policy 1's own
55
+ * answer ("an unknown generation gets no runtime") rather than a gap in this one.
56
+ *
57
+ * ## Purity
58
+ *
59
+ * No clock, no randomness, no filesystem, no network, no `spine-core`. It reads
60
+ * strings and small plain objects and nothing else.
61
+ */
62
+
63
+ /** A generation of Spine data — the `MAJOR.MINOR` pair a runtime is locked to. */
64
+ export type SpineGeneration = '3.8' | '4.0' | '4.1' | '4.2' | '4.3';
65
+
66
+ /**
67
+ * Every generation this module knows, oldest first.
68
+ *
69
+ * The order is load-bearing twice: a down-export chain has to ascend through it,
70
+ * and a caller listing "the generations rigc knows" reads it here rather than
71
+ * typing five strings next to a sixth.
72
+ */
73
+ export const SPINE_GENERATIONS: readonly SpineGeneration[] = ['3.8', '4.0', '4.1', '4.2', '4.3'];
74
+
75
+ /**
76
+ * `MAJOR.MINOR`, then the `-from-` chain, then the patch and its suffix.
77
+ *
78
+ * Anchored at both ends on purpose: `4.30` and `4.3.1.2` are not version strings
79
+ * and a partial match would read them as 4.3. The suffix shape is the one the
80
+ * editor writes for a pre-release (`4.3.75-beta`, which every one of the twelve
81
+ * official example exports declares) and is the same one `A16`'s own regex
82
+ * carried, character for character, before this module took it over.
83
+ */
84
+ const VERSION_STRING = /^(\d+\.\d+)((?:-from-\d+\.\d+)*)(?:\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.+-]*)?)?$/;
85
+
86
+ /**
87
+ * The generation a `skeleton.spine` string names, or `null` for one this module
88
+ * cannot account for.
89
+ *
90
+ * `null` is never the nearest generation and never a guess — see rule 3 above.
91
+ */
92
+ export function spineGeneration(version: string): SpineGeneration | null {
93
+ const match = VERSION_STRING.exec(version);
94
+ if (match === null) return null;
95
+ const chain = match[2] === '' ? [] : match[2].split('-from-').slice(1);
96
+ const known = SPINE_GENERATIONS as readonly string[];
97
+ const steps = [match[1], ...chain].map((token) => known.indexOf(token));
98
+ if (steps.some((at) => at < 0)) return null;
99
+ for (let i = 1; i < steps.length; i++) if (steps[i] <= steps[i - 1]) return null;
100
+ return SPINE_GENERATIONS[steps[0]];
101
+ }
102
+
103
+ /**
104
+ * The constraint kinds a skeleton can carry as a **top-level array**, which 4.3
105
+ * folded into one `constraints` array with a `type` on each entry.
106
+ *
107
+ * 4.3 reads `constraints` and nothing else, so an array under any of these names
108
+ * loads without an error and the constraints in it are simply not there — #706
109
+ * row 1, and what `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` refuses in emitted
110
+ * data. `slider` never had a top-level form (sliders are 4.3's own), and it is
111
+ * on this list for the same reason as the other four: a constraint parked
112
+ * outside `constraints` vanishes whatever its kind, and a list with a hole in it
113
+ * is a rule with a hole in it.
114
+ */
115
+ export const TOPLEVEL_CONSTRAINT_ARRAYS: readonly string[] = ['ik', 'transform', 'path', 'physics', 'slider'];
116
+
117
+ /**
118
+ * The bone key 4.2 spelled `transform` and 4.3 spells `inherit`.
119
+ *
120
+ * The old key does not throw and does not warn — it is an unknown field, so the
121
+ * bone falls back to Normal inheritance (#706 row 6, and
122
+ * `A02_NO_BONE_TRANSFORM_KEY`).
123
+ */
124
+ export const LEGACY_BONE_INHERIT_KEY = 'transform';
125
+
126
+ /**
127
+ * The physics-constraint fields whose **omitted default** is not the same number
128
+ * in 4.2 as in 4.3.
129
+ *
130
+ * JSON omits a field equal to the parser's default, so the same file means two
131
+ * different constraints under two readers: `inertia` and `damping` both default
132
+ * to 1 in 4.2's `SkeletonJson` and to 0.5 and 0.85 in 4.3's (#706 row 4, which
133
+ * also counts 111 shipped constraints omitting `inertia`). The numbers are not
134
+ * repeated here deliberately — a hand-copied table is what #706 policy 3 exists
135
+ * to refuse, and item 2's generated table is where they belong. What a caller
136
+ * needs from this list is which fields to *count*, and that is what it is.
137
+ */
138
+ export const PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED: readonly string[] = ['inertia', 'damping'];