spine-rigc 2.0.2 → 2.0.3

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/README.md CHANGED
@@ -680,7 +680,9 @@ none of it; a skin only one side declares is a FAIL naming it as **lost** (or
680
680
  **added**) **by the export**, with `diff`'s `attachments.skins` beside it, and is
681
681
  rendered on neither side — and a
682
682
  field-by-field list of what the editor rewrote. Every step quotes what its child
683
- said when that child did not do what it was for, the renderers included; a skin
683
+ said when that child did not do what it was for, the renderers included — the
684
+ editor's words in full and in order, except its `Licensed to:` line, which names
685
+ the licence holder and is replaced by a line saying it was withheld; a skin
684
686
  **neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
685
687
  red, because `check` had nothing to compare and `diff` and `validate` have
686
688
  already measured the rig. One side drawing where the other does not is the
package/docs/AUTHORING.md CHANGED
@@ -1008,6 +1008,24 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
1008
1008
  [INGEST.md](INGEST.md) §1.3.1, and *The measure inventory* in
1009
1009
  [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md),
1010
1010
  which is repository material rather than part of this package.
1011
+
1012
+ 🔍 **The wiring is compared by name too, not only the rosters** (issue #1085).
1013
+ `constraints.order` reads the order of the one constraint array, which is the
1014
+ order the runtime applies it in; `animations.targets` reads what each timeline
1015
+ keys and `animations.keyed_names` what each key names — an attachment key's
1016
+ attachment, a draw-order offset's slot, an event key's event; `attachments.refs`
1017
+ reads the region an attachment draws, a clipping polygon's `end` and a linked
1018
+ mesh's `source`; `attachments.skin_members` reads what each skin activates.
1019
+ Every one of those, changed alone, poses or draws differently, and every one read
1020
+ 1.000 before. Each note names what differs, candidate first —
1021
+ `candidate only: "heavy|bones|root"; reference only: "heavy|bones|bone"` is a
1022
+ rotate timeline moved from `bone` to `root`, and `attachment
1023
+ "default/clipping/clipping": end "portal-flare6" vs "head-bb"` is a clipping that
1024
+ stops at another slot. A region's `path` is read as the runtime resolves it —
1025
+ `path`, else `name`, else the key — so stating the path an attachment already
1026
+ resolves is no difference. These are name-matched: your own names read low here
1027
+ exactly as they do on `names`, and a low figure with `names` at 1.000 is the
1028
+ wiring that moved.
1011
1029
  - **`check`** renders your candidate into the reference frames' own pixel grid and
1012
1030
  compares pixels — the only thing here that can see a wrong animation. **§9.**
1013
1031
  🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
package/docs/INGEST.md CHANGED
@@ -64,7 +64,7 @@ an upstream `license.txt` (Appendix, and [NOTICE.md](../NOTICE.md)).
64
64
  | **`preview --candidate <skeleton.json>`** | ✅ **yes** | one self-contained `.html` that plays it in the official Spine Web Player. Needs a network the first time it is opened ([NOTICE.md](../NOTICE.md)) |
65
65
  | **`vote --candidate <a> --candidate <b>`** | ✅ **yes, on either side** | a ballot page. Pairing a foreign export against your own transcription is a legitimate ballot, and the panes carry no paths |
66
66
  | **`check --candidate <skeleton.json> --frames <dir>`** | ✅ **yes** | ⭐ it reads **frames and never a reference skeleton**, so a foreign export enters this one *twice over*: as the candidate, or — via `render` — as the source of the frames. §1.4 |
67
- | **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 49 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
67
+ | **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 54 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
68
68
  | **`ingest <skeleton.json> --out <dir>`** | ✅ **yes — and it is the only reader that WRITES specs** | the `.json` alone; no atlas, no art, no project file. Out come `rig.json`, `motion.json` and a findings report, such that `build`ing them reproduces the skeleton it read **byte for byte — for a skeleton rigc emitted**. ⚠️ For an editor export the claim is identity in canonical form apart from `hash` and `spine`, which §2.3 states in full. The seventh reader, and the one that ends §2's hand work — §2.0 and §5 |
69
69
  | **`pose --images <dir> --frame <png>`** | ⛔ **not the skeleton** | loose part PNGs and one picture. A packed atlas page is not loose parts, and pointing it at one produces a confident answer about nothing — §5 |
70
70
  | **`explain --rig … --motion … --out …`** | ⛔ **no** | rig spec + motion spec. It explains **what you wrote**, which makes it a transcription instrument rather than a reading one — §1.5 |
@@ -215,7 +215,7 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
215
215
 
216
216
  ### 1.3 `diff` — and the two things it cannot see
217
217
 
218
- `diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
218
+ `diff` takes two compiled skeletons and reports 54 measures in eight groups, plus two
219
219
  blocks that report and gate nothing: the `(reported)` measures beside `attachments`
220
220
  and `animations`, and the `skeleton` header block at the top, which measures the stage.
221
221
  A ninth group of six joins them when something has paired the two sides'
@@ -250,7 +250,7 @@ rigc diff
250
250
 
251
251
  bones (name-agnostic) mean 1.000 over 5 measures — the same two skeletons compared with names thrown away
252
252
  …
253
- animations mean 1.000 over 9 measures
253
+ animations mean 1.000 over 11 measures
254
254
  1.000 count 2/2 how many animations
255
255
  1.000 names 2/2 the animation names
256
256
  1.000 duration 2/2 each animation runs as long (last key time, within one frame)
@@ -267,7 +267,7 @@ it does not say *which* side has three. The `..` line above is where you read th
267
267
  ⛔ **`diff` is blind to every coordinate a bone, an attachment or a key carries.** No
268
268
  measure reads a bone's `x`/`y`/`rotation`, an attachment's offset, or a key's value —
269
269
  only *presence*, *names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5
270
- units and every one of the 49 measures still reads **1.000**. ⇒ Never take a green
270
+ units and every one of the 54 measures still reads **1.000**. ⇒ Never take a green
271
271
  `diff` as evidence that a geometric edit did not land, and never take it as evidence
272
272
  that one did.
273
273
 
@@ -325,7 +325,7 @@ two sections, the second over the paired shots — `duration`, `timeline_kinds`,
325
325
  it used:
326
326
 
327
327
  ```
328
- animations mean 0.222 over 9 measures
328
+ animations mean 0.182 over 11 measures
329
329
  1.000 count 1/1 how many animations
330
330
  0.000 names 0/2 the animation names
331
331
  …
@@ -475,8 +475,8 @@ no field of the file).
475
475
  📊 **For an editor export the claim is weaker, and measured.** Every
476
476
  `examples/*/export/*.json` ingested with `--art none`, rebuilt through the pack
477
477
  beside it, and `diff`ed against the file it was read from: **12 of 12 come back with 0
478
- blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones** — and on
479
- all **nine value measures** too, over **193,927** compared values. Byte
478
+ blockers and 1.000 on all 54 ratio-bearing measures and all 6 reported ones** — and on
479
+ all **nine value measures** too, over **195,363** compared values. Byte
480
480
  identity is not the claim there and the reason is the input, not the round trip — §2.3
481
481
  has the pass line, and what the value measures do and do not reach. ⚠️ Which pack is "the one beside it" is
482
482
  resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, and
@@ -702,7 +702,7 @@ State the ambition in the right units, because three different things get called
702
702
 
703
703
  | Ambition | Reachable? | What it costs, and what it proves |
704
704
  | --- | --- | --- |
705
- | **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 49 measures**. Aim here first |
705
+ | **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 54 measures**. Aim here first |
706
706
  | **Geometric agreement** — the same drawn pixels, allowing for the atlas | ✅ yes, and `check` measures it | see below |
707
707
  | **Byte-identical JSON** | ✅ **for a rebuild, in canonical form, apart from `hash` and `spine`** — the pass line below | a **rebuild** of an editor export (`ingest`, then `build`) is the export. A **transcription** by hand is not held to it: what differs there is what a person chose to write, not the emitter |
708
708
 
@@ -745,7 +745,7 @@ keys**, which is why a moved value is invisible to it.
745
745
  ⭐ **The values are measured by a second comparison rather than by `diff`.**
746
746
  Structure at 1.000 is silent about the numbers inside it: a decompiler that halved
747
747
  every rotation, dropped every bone's `length` or mirrored every vertex would read
748
- 1.000 on all 49 measures and on every `(reported)` one. So the example round trip is
748
+ 1.000 on all 54 measures and on every `(reported)` one. So the example round trip is
749
749
  also compared **value by value**,
750
750
  with the format's defaults taken from the parser rather than from a table — both files
751
751
  are read through `spine-core` and the parsed forms are compared path by path, under a
@@ -755,12 +755,12 @@ storage. Nine measures — here is the `6-arcs` export's, wrapped to fit this
755
755
  page:
756
756
 
757
757
  ```
758
- values: 9/9 measure(s) at 1.000 over 13865 compared value(s); skeleton 1.000 ·
758
+ values: 9/9 measure(s) at 1.000 over 13977 compared value(s); skeleton 1.000 ·
759
759
  bones 1.000 · slots 1.000 · attachments 1.000 · constraints 1.000 · events 1.000 ·
760
760
  key_times 1.000 · key_values 1.000 · curves 1.000
761
761
  ```
762
762
 
763
- Over the twelve exports that is **193,927 values** compared, and all twelve read
763
+ Over the twelve exports that is **195,363 values** compared, and all twelve read
764
764
  1.000 on all nine.
765
765
 
766
766
  `docs/BENCHMARK.md`'s *The nine value measures* is the full statement. What it still
@@ -1082,7 +1082,7 @@ same displacement evaluated across the bone's own rotation:
1082
1082
  **What the instruments say about it** — and this pair is why §1.3 carries its warning:
1083
1083
 
1084
1084
  - **`diff` sees nothing.** `rigc diff work/t3b/skeleton.json <the export>` reads
1085
- **1.000 on all 49 measures**: same bones, names, parents, order, slots, draw order,
1085
+ **1.000 on all 54 measures**: same bones, names, parents, order, slots, draw order,
1086
1086
  attachments, animations, timelines, key counts, curve kinds. All true, and all silent
1087
1087
  about a 236.5-unit move.
1088
1088
  - **`check` sees it loudly**, with the framing pinned so the comparison is of pictures
@@ -1192,28 +1192,35 @@ slots to `arm-art`/`block-art` and their attachments to match:
1192
1192
  1.000 bone_binding_shape 2/2 as many slots hang off a bone of each shape (`?` = no such bone is declared)
1193
1193
  1.000 order_shape 2/2 the draw order is the same order of `<attachment type>@<bone shape>`
1194
1194
 
1195
- attachments mean 0.889 over 9 measures
1195
+ attachments mean 0.818 over 11 measures
1196
1196
  1.000 skins 1/1 the skin names
1197
1197
  1.000 count 2/2 how many attachments
1198
1198
  0.000 names 0/4 skin/slot/attachment keys
1199
1199
  1.000 type_counts 2/2 as many of each attachment type
1200
1200
  …
1201
- animations mean 1.000 over 9 measures
1201
+ 0.000 refs 0/2 each attachment resolves the same region, clipping end and linked-mesh source — 2 on one side only, which `attachments.names` names
1202
+ 1.000 skin_members 1/1 each skin activates the same skin-required bones and constraints
1203
+ …
1204
+ animations mean 0.909 over 11 measures
1205
+ …
1206
+ 0.000 targets 0/8 each timeline keys the same bone, slot, constraint or attachment — …
1202
1207
  ```
1203
1208
 
1204
1209
  Three things to read out of that, in order:
1205
1210
 
1206
1211
  - **The name-keyed collapse is the task, not a defect.** `bones` 0.567, `slots` 0.143,
1207
- `attachments` 0.889. Note that several *non*-name measures fall with them —
1212
+ `attachments` 0.818, `animations` 0.909. Note that several *non*-name measures fall with them —
1208
1213
  `slots.bone`, `slots.blend`, `bones.parent_by_name` — because they are keyed **by**
1209
1214
  the name that changed. They are not saying the binding changed.
1210
1215
  - 🚨 **`bones (name-agnostic)` and `slots (name-agnostic)` must stay 1.000.** They
1211
1216
  measure the rig with the vocabulary thrown away, so a rename that changed only names
1212
1217
  leaves them untouched. **A drop there is a structural mistake wearing a rename's
1213
1218
  clothes**, and it is the only assertion this recipe really has.
1214
- - **`animations` stays 1.000** because animation *names* were not part of the ask.
1215
- Under the same edit `bones.names` reads `1/5` — `root` survived, and the total is the
1216
- union of both vocabularies.
1219
+ - **`animations` moves on one measure only, `targets`**, because animation *names*
1220
+ were not part of the ask but every timeline keys a renamed bone — `targets` is keyed
1221
+ by the name that changed, as `slots.bone` is. Every other `animations` measure stays
1222
+ 1.000. Under the same edit `bones.names` reads `1/5` — `root` survived, and the total
1223
+ is the union of both vocabularies.
1217
1224
 
1218
1225
  ⛔ And remember §1.3: `diff` reads no coordinates either way. A rename that also moved
1219
1226
  something is invisible to every measure in that report. Pair it with a `check` against
@@ -1255,7 +1262,7 @@ that already round-trips.
1255
1262
  4. **Read `diff` knowing what it is about to say.**
1256
1263
 
1257
1264
  ```
1258
- animations mean 0.793 over 9 measures
1265
+ animations mean 0.821 over 11 measures
1259
1266
  0.667 count 2/3 how many animations
1260
1267
  0.667 names 2/3 the animation names
1261
1268
  0.667 duration 2/3 each animation runs as long (last key time, within one frame)
@@ -1265,10 +1272,12 @@ that already round-trips.
1265
1272
  1.000 event_keys 0/0 as many event firings — neither side has any
1266
1273
  0.667 draw_order 2/3 a draw-order timeline is present or absent alike
1267
1274
  0.667 deform 2/3 a deform timeline is present or absent alike
1275
+ 0.889 targets 8/9 each timeline keys the same bone, slot, constraint or attachment — …
1276
+ 1.000 keyed_names 0/0 each key names the same attachment, draw-order slot or event — neither side has any
1268
1277
  ```
1269
1278
 
1270
1279
  🚨 **Every one of those got worse, and that is the correct result.** The
1271
- `animations` section went 1.000 → 0.793 because the candidate now has something the
1280
+ `animations` section went 1.000 → 0.821 because the candidate now has something the
1272
1281
  reference does not. `diff` measures agreement with a reference; you were asked to
1273
1282
  *disagree* with it, in one specific way. ⇒ **Check that the drop is confined to the
1274
1283
  `animations` section and is the size the addition explains** — one animation of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -7482,7 +7482,10 @@ function buildMesh(
7482
7482
  );
7483
7483
 
7484
7484
  return {
7485
- kind: geometry.kind,
7485
+ // `kind` is what chose the builder above, so it is the claim; `geometry.kind`
7486
+ // is the wider `MeshKind`, which a strict tsc refuses here (and a dependant
7487
+ // with `strict: true` reads this file through `src/render.ts`).
7488
+ kind,
7486
7489
  attachment: {
7487
7490
  kind: 'mesh',
7488
7491
  uvs: geometry.uvs.map(f32),
package/src/diff.ts CHANGED
@@ -689,6 +689,43 @@ interface AttachmentFact {
689
689
  * — see `attachments.runtime_name`.
690
690
  */
691
691
  runtimeName: string;
692
+ /**
693
+ * Every name the attachment resolves, as `<field>:<name>` and sorted — see
694
+ * `attachmentRefTokens` for which fields and why.
695
+ */
696
+ refs: string;
697
+ /** The same tokens as a list, for the note that spells a disagreement out. */
698
+ refList: string[];
699
+ }
700
+
701
+ /**
702
+ * The names an attachment resolves (issue #1085): the atlas region a region or
703
+ * a mesh draws, a clipping polygon's end slot, and the mesh a linked mesh takes
704
+ * its geometry from — with the skin and slot it is found under, where stated.
705
+ *
706
+ * ⭐ The region is read as the runtime resolves it, `path`, else the stated
707
+ * `name`, else the placeholder (SPEC_COVERAGE §1.5's three-level indirection),
708
+ * for the reason `runtime_name` reads its name that way: a file that spells the
709
+ * path out and one that leaves it to the parser name the same region, and a
710
+ * measure that called those different would score every export against every
711
+ * writer that states its paths. Every other field is a token only when the file
712
+ * states it — absent is no token, never a default, as in `constraints.refs`.
713
+ *
714
+ * Measured on public builds through spine-core: a region's `path` moved to the
715
+ * other eye of `spineboy-pro` changes 94 of its 99 sampled draws, its clipping
716
+ * polygon's `end` moved six slots on changes the clipping of 9, and a linked
717
+ * mesh on `gallery/nod` taking `ear_r` instead of `ear_l` as its source changes
718
+ * all 18 of its sampled poses. `diff` compared none of the three, and two of them occur in
719
+ * none of the twelve editor exports (a stated `path` and a linked mesh).
720
+ */
721
+ function attachmentRefTokens(type: string, key: string, att: Json): string[] {
722
+ const out: string[] = [];
723
+ if (type === 'region' || type === 'mesh' || type === 'linkedmesh') out.push(`path:${str(att.path) ?? str(att.name) ?? key}`);
724
+ for (const field of ['end', 'source', 'skin', 'slot'] as const) {
725
+ const s = str(att[field]);
726
+ if (s !== null) out.push(`${field}:${s}`);
727
+ }
728
+ return out.sort();
692
729
  }
693
730
 
694
731
  function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
@@ -725,6 +762,8 @@ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, A
725
762
  edgesPresent: type === 'mesh' ? 'edges' in att : null,
726
763
  size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
727
764
  runtimeName: str(att.name) ?? attName,
765
+ refs: attachmentRefTokens(type, attName, att).join(' '),
766
+ refList: attachmentRefTokens(type, attName, att),
728
767
  });
729
768
  }
730
769
  }
@@ -795,6 +834,22 @@ function diffAttachments(c: Json, r: Json): DiffSection {
795
834
  counted([...ar.values()].map((f) => f.size)),
796
835
  counted([...br.values()].map((f) => f.size)),
797
836
  ),
837
+ wiringAgreement(
838
+ 'attachments.refs',
839
+ 'each attachment resolves the same region, clipping end and linked-mesh source',
840
+ a.byKey,
841
+ b.byKey,
842
+ (key) => `attachment ${JSON.stringify(key)}`,
843
+ '`attachments.names`',
844
+ ),
845
+ wiringAgreement(
846
+ 'attachments.skin_members',
847
+ 'each skin activates the same skin-required bones and constraints',
848
+ skinMembers(c),
849
+ skinMembers(r),
850
+ (skin) => `skin ${JSON.stringify(skin)}`,
851
+ '`attachments.skins`',
852
+ ),
798
853
  ],
799
854
  undefined,
800
855
  // ── reported (issue #46) ────────────────────────────────────────────────
@@ -853,6 +908,67 @@ function diffAttachments(c: Json, r: Json): DiffSection {
853
908
  ]);
854
909
  }
855
910
 
911
+ /**
912
+ * What each skin activates (issue #1085): the `bones` and the per-kind
913
+ * constraint lists a skin declares, as `<list>:<name>` tokens, sorted — a skin's
914
+ * MEMBERSHIP, which is what decides whether a bone or constraint marked
915
+ * `skin: true` is active under it.
916
+ *
917
+ * Measured on public builds through spine-core: a `gallery/look` bone made
918
+ * skin-required and left out of the skin's `bones` moves every one of its 27
919
+ * sampled poses, and `gallery/walk`'s `leg_b_ik` left out of the skin's `ik`
920
+ * moves all 9. None of the twelve editor exports declares a membership list, so
921
+ * every one of them reads `1/1` per skin here — two empty lists agree — which is
922
+ * the comparison being made rather than an absence of one.
923
+ */
924
+ function skinMembers(root: Json): Map<string, { refs: string; refList: string[] }> {
925
+ const out = new Map<string, { refs: string; refList: string[] }>();
926
+ for (const skin of objs(root.skins)) {
927
+ const name = str(skin.name) ?? 'default';
928
+ const refList: string[] = [];
929
+ for (const list of ['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const) {
930
+ for (const member of arr(skin[list])) {
931
+ const s = str(member);
932
+ if (s !== null) refList.push(`${list}:${s}`);
933
+ }
934
+ }
935
+ refList.sort();
936
+ out.set(name, { refs: refList.join(' '), refList });
937
+ }
938
+ return out;
939
+ }
940
+
941
+ /**
942
+ * `agreement` over the larger roster on a `refs` string, with the note
943
+ * `constraints.refs` writes: each entry both sides hold whose names differ,
944
+ * field by field and candidate first, then a count of the entries one side holds
945
+ * alone, which `elsewhere` is the measure that names.
946
+ */
947
+ function wiringAgreement<T extends { refs: string; refList: string[] }>(
948
+ id: string,
949
+ what: string,
950
+ a: Map<string, T>,
951
+ b: Map<string, T>,
952
+ label: (key: string) => string,
953
+ elsewhere: string,
954
+ ): DiffMeasure {
955
+ const base = agreement(id, what, a, b, (x, y) => x.refs === y.refs);
956
+ const missed = base.total - base.matched;
957
+ if (missed === 0) return base;
958
+ const differ: string[] = [];
959
+ for (const [key, fact] of a) {
960
+ const other = b.get(key);
961
+ if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(label(key), fact, other));
962
+ }
963
+ const oneSided = missed - differ.length;
964
+ const spelled = differ.slice(0, REFS_SPELLED_OUT);
965
+ const note =
966
+ (differ.length === 0 ? '' : `${differ.length} differ: ${spelled.join('; ')}`) +
967
+ (differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
968
+ (oneSided === 0 ? '' : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which ${elsewhere} names`);
969
+ return { ...base, note };
970
+ }
971
+
856
972
  /**
857
973
  * `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
858
974
  * hold, how many answer to the same runtime name — see the note at its call.
@@ -921,7 +1037,8 @@ interface ConstraintFact {
921
1037
  * property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
922
1038
  * a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
923
1039
  * `x>y` moved no measure here and no value in the value walk either (its skip
924
- * list names `properties`), so nothing in the tree compared them at all.
1040
+ * list named `properties`), so nothing in the tree compared them at all. Since
1041
+ * issue #1084 the walk reads both, by class (`property/kind`, `properties/FromX/to/ToY`).
925
1042
  *
926
1043
  * ⚠️ Absent is no token, never a default: a transform with no `properties` maps
927
1044
  * nothing, and two sides that both omit a field agree about it. And the tokens
@@ -967,8 +1084,8 @@ const REFS_SPELLED_OUT = 3;
967
1084
  * "5" vs "-a"` — the candidate's names for each field that differs, then the
968
1085
  * reference's, `none` where a side has none.
969
1086
  */
970
- function refsDisagreement(at: string, a: ConstraintFact, b: ConstraintFact): string {
971
- const byField = (f: ConstraintFact): Map<string, string[]> => {
1087
+ function refsDisagreement(at: string, a: { refList: string[] }, b: { refList: string[] }): string {
1088
+ const byField = (f: { refList: string[] }): Map<string, string[]> => {
972
1089
  const m = new Map<string, string[]>();
973
1090
  for (const token of f.refList) {
974
1091
  const colon = token.indexOf(':');
@@ -1024,9 +1141,43 @@ function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, Constrain
1024
1141
  return { ...base, note };
1025
1142
  }
1026
1143
 
1144
+ /**
1145
+ * The constraints in the order the file declares them, by kind and name — the
1146
+ * key `constraintFacts` uses, so that `leg` as an ik and as a transform are two
1147
+ * entries here as well.
1148
+ *
1149
+ * 🔍 **Why the order is data** (issue #1085). 4.3 folds every constraint into one
1150
+ * array and applies them in its order, so two files with the same constraints
1151
+ * wired the same way can pose differently. Measured by reversing the array and
1152
+ * posing both through spine-core: `spineboy-pro` moves 98 of its 99 sampled
1153
+ * poses, `6-arcs-pro` 8 of 9 and `sack-pro` 8 of 36 (33 of 36 with physics
1154
+ * stepped); swapping `aim-torso-ik` and `aim-torso-transform` alone moves the 9
1155
+ * samples of `aim`. Before this nothing in the tree compared it — the value walk
1156
+ * keys constraints by name, so it is blind to the order by construction.
1157
+ *
1158
+ * ⚠️ Not every swap poses differently, and the measure does not pretend to know
1159
+ * which do: `gallery/look`'s two sliders and `gallery/walk`'s two legs drive
1160
+ * disjoint bones and read the same pose either way. A reordering is reported as
1161
+ * what the file states, the way `bones.order` reports one.
1162
+ */
1163
+ function constraintOrder(root: Json): string[] {
1164
+ const out: string[] = [];
1165
+ for (const con of objs(root.constraints)) {
1166
+ const name = str(con.name);
1167
+ if (name !== null) out.push(constraintAt(str(con.type) ?? '(none)', name));
1168
+ }
1169
+ return out;
1170
+ }
1171
+
1027
1172
  function diffConstraints(c: Json, r: Json): DiffSection {
1028
1173
  const a = constraintFacts(c);
1029
1174
  const b = constraintFacts(r);
1175
+ const ao = constraintOrder(c);
1176
+ const bo = constraintOrder(r);
1177
+ const shared = ao.filter((n) => b.has(n));
1178
+ const sharedOther = bo.filter((n) => a.has(n));
1179
+ const inOrder = lcs(shared, sharedOther);
1180
+ const max = Math.max(a.size, b.size);
1030
1181
  return sectionOf('constraints', [
1031
1182
  measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
1032
1183
  jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
@@ -1038,9 +1189,32 @@ function diffConstraints(c: Json, r: Json): DiffSection {
1038
1189
  ),
1039
1190
  agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
1040
1191
  constraintRefs(a, b),
1192
+ measure(
1193
+ 'constraints.order',
1194
+ 'the constraints are declared, and so applied, in the same order',
1195
+ inOrder,
1196
+ max,
1197
+ [
1198
+ ...(inOrder === shared.length ? [] : [outOfOrder(shared, sharedOther)]),
1199
+ ...(shared.length === max ? [] : [`${max - shared.length} of the larger roster not on both sides, which \`constraints.names\` names`]),
1200
+ ].join('; ') || undefined,
1201
+ ),
1041
1202
  ]);
1042
1203
  }
1043
1204
 
1205
+ /**
1206
+ * `constraints.order`'s note when the shared constraints are not in one order:
1207
+ * both sides' order of them, candidate first, spelled out to a limit.
1208
+ */
1209
+ function outOfOrder(a: string[], b: string[]): string {
1210
+ const spell = (list: string[]): string =>
1211
+ list.slice(0, ORDER_SPELLED_OUT).join(', ') + (list.length > ORDER_SPELLED_OUT ? `, …and ${list.length - ORDER_SPELLED_OUT} more` : '');
1212
+ return `${a.length - lcs(a, b)} out of order: candidate ${spell(a)}; reference ${spell(b)}`;
1213
+ }
1214
+
1215
+ /** How many constraints `constraints.order`'s note lists per side before it counts the rest. */
1216
+ const ORDER_SPELLED_OUT = 6;
1217
+
1044
1218
  interface AnimationFacts {
1045
1219
  names: string[];
1046
1220
  /** `<anim>` -> last key time. Skeleton JSON has no duration field. */
@@ -1098,6 +1272,136 @@ function animationFacts(root: Json): AnimationFacts {
1098
1272
  return facts;
1099
1273
  }
1100
1274
 
1275
+ /**
1276
+ * The names the animations resolve, as two multisets of tokens (issue #1085).
1277
+ *
1278
+ * `animationFacts` keeps each timeline's kind and drops its target on purpose —
1279
+ * a candidate's own vocabulary would otherwise be counted against it a second
1280
+ * time inside `timeline_kinds` (#21) — and reads no key's contents beyond its
1281
+ * time and curve. So until this, a bone timeline moved onto another bone, an
1282
+ * attachment key naming another attachment, a draw-order key naming another slot
1283
+ * and an event key firing another event each moved no measure in this file, and
1284
+ * every one of them poses or draws differently: measured on public builds
1285
+ * through spine-core, a retargeted bone timeline moves 7 of `gallery/look`'s 27
1286
+ * sampled poses, two slots' attachment keys swapped move 9 of `spineboy-pro`'s
1287
+ * 99, a draw-order offset moved to the next slot moves 9 of `spineboy-ess`'s 72
1288
+ * and an event key renamed to a second event moves the event block of one sample.
1289
+ *
1290
+ * - `targets`: one token per timeline, `<animation>|<group>|<target>` — the
1291
+ * bone, slot or constraint it keys, a deform's or sequence's
1292
+ * `skin/slot/attachment`, and each slot a draw-order folder holds. The
1293
+ * timeline's own kind is left out on purpose: it is `timeline_kinds`'
1294
+ * subject, and a rotate keyed as a scale on the same bone moves that measure
1295
+ * and must not move this one too.
1296
+ * - `keyed`: one token per name a KEY carries, with its position in the
1297
+ * timeline — an attachment key's attachment (`(none)` for a key that clears
1298
+ * the slot), each draw-order offset's slot, an event key's event.
1299
+ *
1300
+ * Both are names, so both are name-matched, and a candidate with its own names
1301
+ * reads low here exactly as it does on `names` — the name-agnostic block is
1302
+ * where that rig is read. ⚠️ No key VALUE is read: a key's time, an offset's
1303
+ * distance and an event's payload are the value walk's.
1304
+ */
1305
+ interface AnimationRefs {
1306
+ targets: Map<string, number>;
1307
+ keyed: Map<string, number>;
1308
+ }
1309
+
1310
+ /** The physics group's empty name, which applies a timeline to every physics constraint. */
1311
+ const EVERY_PHYSICS_CONSTRAINT = '(every physics constraint)';
1312
+
1313
+ function animationRefs(root: Json): AnimationRefs {
1314
+ const targets = new Map<string, number>();
1315
+ const keyed = new Map<string, number>();
1316
+ const bump = (m: Map<string, number>, k: string): void => {
1317
+ m.set(k, (m.get(k) ?? 0) + 1);
1318
+ };
1319
+ if (!isObj(root.animations)) return { targets, keyed };
1320
+ for (const [anim, body] of Object.entries(root.animations)) {
1321
+ if (!isObj(body)) continue;
1322
+ for (const group of ['bones', 'slots', 'path', 'physics', 'slider'] as const) {
1323
+ const held = body[group];
1324
+ if (!isObj(held)) continue;
1325
+ for (const [target, timelines] of Object.entries(held)) {
1326
+ if (!isObj(timelines)) continue;
1327
+ const named = group === 'physics' && target === '' ? EVERY_PHYSICS_CONSTRAINT : target;
1328
+ for (const [timeline, keys] of Object.entries(timelines)) {
1329
+ if (!Array.isArray(keys)) continue;
1330
+ bump(targets, `${anim}|${group}|${named}`);
1331
+ if (group === 'slots' && timeline === 'attachment') {
1332
+ keys.forEach((key, i) => {
1333
+ if (isObj(key)) bump(keyed, `${anim}|attachment key ${i}|${target}: ${str(key.name) ?? '(none)'}`);
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ for (const group of ['ik', 'transform'] as const) {
1340
+ const held = body[group];
1341
+ if (!isObj(held)) continue;
1342
+ for (const [target, keys] of Object.entries(held)) if (Array.isArray(keys)) bump(targets, `${anim}|${group}|${target}`);
1343
+ }
1344
+ if (isObj(body.attachments)) {
1345
+ for (const [skin, bySlot] of Object.entries(body.attachments)) {
1346
+ if (!isObj(bySlot)) continue;
1347
+ for (const [slot, byAttachment] of Object.entries(bySlot)) {
1348
+ if (!isObj(byAttachment)) continue;
1349
+ for (const [attachment, timelines] of Object.entries(byAttachment)) {
1350
+ if (!isObj(timelines)) continue;
1351
+ for (const keys of Object.values(timelines)) {
1352
+ if (Array.isArray(keys)) bump(targets, `${anim}|attachments|${skin}/${slot}/${attachment}`);
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ }
1358
+ const offsets = (label: string, keys: unknown): void => {
1359
+ arr(keys).forEach((key, i) => {
1360
+ if (!isObj(key)) return;
1361
+ for (const offset of objs(key.offsets)) bump(keyed, `${anim}|${label} key ${i}|${str(offset.slot) ?? '(none)'}`);
1362
+ });
1363
+ };
1364
+ offsets('drawOrder', body.drawOrder);
1365
+ objs(body.drawOrderFolder).forEach((folder, f) => {
1366
+ for (const slot of arr(folder.slots)) bump(targets, `${anim}|drawOrderFolder ${f}|${str(slot) ?? '(none)'}`);
1367
+ offsets(`drawOrderFolder ${f}`, folder.keys);
1368
+ });
1369
+ arr(body.events).forEach((key, i) => {
1370
+ if (isObj(key)) bump(keyed, `${anim}|event key ${i}|${str(key.name) ?? '(none)'}`);
1371
+ });
1372
+ }
1373
+ return { targets, keyed };
1374
+ }
1375
+
1376
+ /** How many tokens a names histogram's note spells out per side before it counts the rest. */
1377
+ const TOKENS_SPELLED_OUT = 3;
1378
+
1379
+ /**
1380
+ * `histogram`, with a note naming what each side holds that the other does not —
1381
+ * the figure alone says some name moved, and a reader acting on it needs which.
1382
+ */
1383
+ function namesHistogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
1384
+ const base = histogram(id, what, a, b);
1385
+ if (base.matched === base.total) return base;
1386
+ // A token one side holds more often than the other, with how many more — a
1387
+ // bone keyed by three timelines on one side and one on the other is `×2`.
1388
+ const only = (x: Map<string, number>, y: Map<string, number>): string[] => {
1389
+ const out: string[] = [];
1390
+ for (const [k, v] of x) {
1391
+ const extra = v - (y.get(k) ?? 0);
1392
+ if (extra > 0) out.push(`${JSON.stringify(k)}${extra > 1 ? ` ×${extra}` : ''}`);
1393
+ }
1394
+ return out;
1395
+ };
1396
+ const spell = (side: string, list: string[]): string =>
1397
+ list.length === 0
1398
+ ? ''
1399
+ : `${side} only: ${list.slice(0, TOKENS_SPELLED_OUT).join(', ')}` +
1400
+ (list.length > TOKENS_SPELLED_OUT ? `, …and ${list.length - TOKENS_SPELLED_OUT} more` : '');
1401
+ const parts = [spell('candidate', only(a, b)), spell('reference', only(b, a))].filter((p) => p !== '');
1402
+ return { ...base, note: `${base.total - base.matched} differ — ${parts.join('; ')}` };
1403
+ }
1404
+
1101
1405
  /**
1102
1406
  * What a side's keying costs, in the two shapes that separate the two ways a
1103
1407
  * key total can differ: more timelines, or more keys inside a timeline.
@@ -1266,6 +1570,8 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1266
1570
  const at = keyingTotals(a);
1267
1571
  const bt = keyingTotals(b);
1268
1572
  const paired = pairAnimations(a, b, pairsStated);
1573
+ const refsA = animationRefs(c);
1574
+ const refsB = animationRefs(r);
1269
1575
  const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
1270
1576
  const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
1271
1577
  return sectionOf('animations', [
@@ -1284,6 +1590,18 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1284
1590
  histogram('animations.event_keys', 'as many event firings', a.events, b.events),
1285
1591
  agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
1286
1592
  agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
1593
+ namesHistogram(
1594
+ 'animations.targets',
1595
+ 'each timeline keys the same bone, slot, constraint or attachment',
1596
+ refsA.targets,
1597
+ refsB.targets,
1598
+ ),
1599
+ namesHistogram(
1600
+ 'animations.keyed_names',
1601
+ 'each key names the same attachment, draw-order slot or event',
1602
+ refsA.keyed,
1603
+ refsB.keyed,
1604
+ ),
1287
1605
  ],
1288
1606
  // ── the same two skeletons' shots, paired rather than named (issue #720) ──
1289
1607
  //
@@ -1628,7 +1946,7 @@ export const VALUE_PARSED_ULP = 2 ** -23;
1628
1946
  * Both terms are derived rather than fitted: the first is rigc's models' grid,
1629
1947
  * the second is the parser's storage. Measured over the twelve editor exports
1630
1948
  * in `examples/`, the widest gap between a rebuild and the file it was read
1631
- * from is **0** — none of 188,339 numeric values differs at all, since issue
1949
+ * from is **0** — none of 189,699 numeric values differs at all, since issue
1632
1950
  * #716 made every emitted number its float's own name (it was 0.81 of this
1633
1951
  * bound, over 56,951 values that differed, while rigc emitted six decimals) —
1634
1952
  * so the corpus sits inside a bound that was not drawn around it, and no
@@ -1657,7 +1975,11 @@ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }
1657
1975
  { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1658
1976
  { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1659
1977
  { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
1660
- { id: 'values.constraints', what: 'every constraint pose field and flag', prefix: 'constraints/' },
1978
+ {
1979
+ id: 'values.constraints',
1980
+ what: 'every constraint field the parser reads: pose, flags, offsets, and the property map and its kinds',
1981
+ prefix: 'constraints/',
1982
+ },
1661
1983
  { id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
1662
1984
  { id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
1663
1985
  { id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
package/src/validate.ts CHANGED
@@ -3466,10 +3466,70 @@ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
3466
3466
  index: 'the array position this walk already iterates by name',
3467
3467
  timelineIds: 'derived from the timelines',
3468
3468
  timelineSlots: 'derived from the skin',
3469
- properties: 'derived from the constraint',
3470
3469
  propertyIds: 'carries an attachment id, which is a counter (see above)',
3471
3470
  };
3472
3471
 
3472
+ /**
3473
+ * The keys above that ARE a value the file carries on these owners, by the
3474
+ * owner's class name (issue #1084).
3475
+ *
3476
+ * The skip list is keyed by field name alone, and four of its names are also
3477
+ * the name of a field the parser reads off the file on a different class. Each
3478
+ * owner below was found by walking the parsed form of the twelve editor exports
3479
+ * and the seven gallery builds for every class carrying a key the list names,
3480
+ * and each field was planted and posed before it was let back in:
3481
+ *
3482
+ * - `a`, `b` — a `Color`'s alpha and blue channels, which share their names with
3483
+ * two entries of a bone's world matrix. Every slot colour, dark colour, bone
3484
+ * colour and attachment colour lost half its channels to that: a slot's alpha
3485
+ * `ff` → `80` moved no value at all, and posed, it changes every slot row.
3486
+ * - `local` — a slider's `local` flag, which shares its name with a bone pose's
3487
+ * derived `local`. Flipped on `gallery/look`'s `yaw`, it moves the posed bones.
3488
+ * - `offsets` — a transform constraint's six offsets (`rotation`, `x`, `y`,
3489
+ * `scaleX`, `scaleY`, `shearY` in the file), which share their name with a
3490
+ * sequence's region offsets. A `y` offset moved on `6-arcs-pro`'s `tail`
3491
+ * moved no value and moves the posed bones.
3492
+ *
3493
+ * A fifth entry, `properties` (*derived from the constraint*), is gone rather
3494
+ * than excepted: the one class measured carrying it is the transform constraint,
3495
+ * where it is the file's from/to map, so the reason was not true of anything.
3496
+ *
3497
+ * ⚠️ An exception rather than a rewrite of the list: the list's design — a
3498
+ * field the parser starts reading is compared the day it starts — is right,
3499
+ * and what was wrong was that a name stood for every class carrying it.
3500
+ */
3501
+ const VALUE_ON_THESE_OWNERS: Record<string, readonly string[]> = {
3502
+ a: ['Color'],
3503
+ b: ['Color'],
3504
+ local: ['SliderData'],
3505
+ offsets: ['TransformConstraintData'],
3506
+ };
3507
+
3508
+ /** Whether the walk leaves `key` out on an object of class `owner`. */
3509
+ function notAValue(owner: string, key: string): boolean {
3510
+ return key in NOT_A_VALUE_IN_THE_FILE && !(VALUE_ON_THESE_OWNERS[key] ?? []).includes(owner);
3511
+ }
3512
+
3513
+ /**
3514
+ * The class an object was parsed into when that class is one of several sharing
3515
+ * a shape — it extends another class — or `null`.
3516
+ *
3517
+ * Reflection reads fields, and two classes with the same fields are the same
3518
+ * object to it: a slider reading `rotate` holds a `FromRotate` and one reading
3519
+ * `x` a `FromX`, both `{ offset, to }`, so moving a slider to another property
3520
+ * moved no value (issue #1084). Measured over the parsed corpus, the only
3521
+ * unnamed objects the walk expands whose class extends another are the
3522
+ * transform and slider property classes (`From*`, `To*`); a pose, a colour and a
3523
+ * sequence are classes of their own, so this adds nothing under them.
3524
+ */
3525
+ function sharedShapeKind(obj: object): string | null {
3526
+ const proto = Object.getPrototypeOf(obj) as object | null;
3527
+ if (proto === null || proto === Object.prototype) return null;
3528
+ const parent = Object.getPrototypeOf(proto) as object | null;
3529
+ if (parent === null || parent === Object.prototype) return null;
3530
+ return (obj.constructor as { name?: string } | undefined)?.name ?? null;
3531
+ }
3532
+
3473
3533
  /** How deep a chain of unnamed objects may go before the walk says so and stops. */
3474
3534
  const VALUE_WALK_DEPTH = 10;
3475
3535
 
@@ -3501,7 +3561,24 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
3501
3561
  if (typeof value === 'function') return;
3502
3562
  if (ArrayBuffer.isView(value) || Array.isArray(value)) {
3503
3563
  const list = value as ArrayLike<unknown>;
3504
- for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${i}`, out, depth + 1);
3564
+ // A list whose every entry is an unnamed object of its own shared-shape
3565
+ // class — a transform's `properties` and each one's `to` — is keyed by those
3566
+ // classes rather than by position: the file writes them as an object keyed
3567
+ // by property name, any writer may key it in its own order, and the
3568
+ // parser's list follows that order. By position, the same map written
3569
+ // backwards would read as every property moved (issue #1084).
3570
+ const kinds: string[] = [];
3571
+ for (let i = 0; i < list.length; i++) {
3572
+ const entry = list[i];
3573
+ const kind =
3574
+ typeof entry === 'object' && entry !== null && typeof (entry as Record<string, unknown>).name !== 'string'
3575
+ ? sharedShapeKind(entry)
3576
+ : null;
3577
+ if (kind === null || kinds.includes(kind)) break;
3578
+ kinds.push(kind);
3579
+ }
3580
+ const byKind = list.length > 0 && kinds.length === list.length;
3581
+ for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${byKind ? kinds[i] : i}`, out, depth + 1);
3505
3582
  return;
3506
3583
  }
3507
3584
  if (typeof value !== 'object') return;
@@ -3514,8 +3591,12 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
3514
3591
  out.push({ path, value: '(deeper than the walk goes)' });
3515
3592
  return;
3516
3593
  }
3594
+ const owner = (obj.constructor as { name?: string } | undefined)?.name ?? '';
3595
+ // The class, where it is the one thing the fields cannot say — see `sharedShapeKind`.
3596
+ const kind = depth > 0 ? sharedShapeKind(obj) : null;
3597
+ if (kind !== null) out.push({ path: `${path}/kind`, value: kind });
3517
3598
  for (const key of Object.keys(obj).sort()) {
3518
- if (key in NOT_A_VALUE_IN_THE_FILE) continue;
3599
+ if (notAValue(owner, key)) continue;
3519
3600
  pushValue(obj[key], `${path}/${key}`, out, depth + 1);
3520
3601
  }
3521
3602
  }
@@ -3570,7 +3651,9 @@ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkel
3570
3651
  * and setup attachment, every attachment in every skin (a region's offsets,
3571
3652
  * rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
3572
3653
  * triangles, hull and edges; a bounding box's, path's and clipping shape's
3573
- * vertices), every constraint's pose and flags, every event's payload, and for
3654
+ * vertices), every constraint's pose, flags and modes — a transform's offsets
3655
+ * and its property map, each entry by its class, and a slider's property by its
3656
+ * class (issue #1084) — every event's payload, and for
3574
3657
  * every timeline every frame — time and values, from the runtime's own
3575
3658
  * `getFrameEntries()` — its curve type and Bezier samples, and its deform
3576
3659
  * vertices, attachment names, draw orders and event payloads.
@@ -3605,7 +3688,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3605
3688
  const at = `bones/${bone.name}`;
3606
3689
  const rec = bone as unknown as Record<string, unknown>;
3607
3690
  for (const key of Object.keys(rec).sort()) {
3608
- if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
3691
+ if (notAValue('BoneData', key) || key === 'name') continue;
3609
3692
  pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3610
3693
  }
3611
3694
  }
@@ -3613,7 +3696,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3613
3696
  const at = `slots/${slot.name}`;
3614
3697
  const rec = slot as unknown as Record<string, unknown>;
3615
3698
  for (const key of Object.keys(rec).sort()) {
3616
- if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
3699
+ if (notAValue('SlotData', key) || key === 'name') continue;
3617
3700
  pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3618
3701
  }
3619
3702
  }
@@ -3681,7 +3764,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3681
3764
  }
3682
3765
  const rec = timeline as unknown as Record<string, unknown>;
3683
3766
  for (const field of Object.keys(rec).sort()) {
3684
- if (field in NOT_A_VALUE_IN_THE_FILE || field === 'frames') continue;
3767
+ if (notAValue(timeline.constructor?.name ?? '', field) || field === 'frames') continue;
3685
3768
  // The owner is in the path already; comparing the index as well would
3686
3769
  // report one reordering twice, in a measure that is not about order.
3687
3770
  if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
@@ -308,6 +308,61 @@ interface Ran {
308
308
  timedOut: boolean;
309
309
  }
310
310
 
311
+ /**
312
+ * The indices of the lines in one stream of editor output that carry the
313
+ * licence holder, and nothing else (issue #1082).
314
+ *
315
+ * Measured on a licensed 4.3 editor on macOS 2026-10-02, through this tool, on
316
+ * four failing calls. Every one exited 1, wrote everything to stdout and nothing
317
+ * to stderr, and printed one of two shapes:
318
+ *
319
+ * an input path that does not exist (import, and export) — refused by the
320
+ * LAUNCHER before the editor starts, and no licence line at all:
321
+ *
322
+ * the launcher's banner, the copyright line, the operating system
323
+ * (blank) `Parameter: --input <path>` (blank)
324
+ * `ERROR: Input path does not exist:`
325
+ * the path
326
+ *
327
+ * a file the editor cannot read (a skeleton JSON naming a parent bone it does
328
+ * not declare; a `.spine` that is not a project) — the editor starts, names
329
+ * itself and its licence holder, and then the failure:
330
+ *
331
+ * the launcher's banner, the copyright line, the operating system
332
+ * `Starting: Spine <x.y.z> Professional`
333
+ * `Spine <x.y.z> Professional`
334
+ * `Licensed to: <name> <<e-mail>>` <- ONE line, withheld
335
+ * import: `Project import: …`, `ERROR: Unable to import skeleton.`, then
336
+ * export: `ERROR: Unable to export.`, then
337
+ * `[error] …` lines, a stack, and `Cause: …` lines
338
+ *
339
+ * 🚨 So on a failure the block is one line, not the three `--version` prints
340
+ * (`Licensed to:` alone, then the name, then the e-mail — issue #1077), and
341
+ * the lines straight after it are the editor's own error: a rule that took
342
+ * "the header and the two lines after it" would have quoted the licence holder
343
+ * not at all and the reason for the failure not at all either.
344
+ *
345
+ * ⚠️ Both shapes are held, each by what bounds it. A header with a value on the
346
+ * same line is that one line. A bare header takes the two lines after it only
347
+ * when the second of them is an e-mail (`@`), which is the `--version` shape;
348
+ * anything else after a bare header is the editor talking and is quoted.
349
+ */
350
+ function licenceLines(lines: readonly string[]): Set<number> {
351
+ const withheld = new Set<number>();
352
+ for (let i = 0; i < lines.length; i++) {
353
+ const header = /^[ \t]*Licensed to:(.*)$/.exec(lines[i]);
354
+ if (header === null) continue;
355
+ withheld.add(i);
356
+ if (header[1].trim() !== '') continue;
357
+ if (i + 2 < lines.length && lines[i + 2].includes('@')) {
358
+ withheld.add(i + 1);
359
+ withheld.add(i + 2);
360
+ i += 2;
361
+ }
362
+ }
363
+ return withheld;
364
+ }
365
+
311
366
  /**
312
367
  * Everything the editor printed on a step that did not do what it was for.
313
368
  *
@@ -336,6 +391,11 @@ interface Ran {
336
391
  * softening the verdict. What it prints is the editor's own words, quoted and
337
392
  * attributed to the stream they came off, and never rewritten — a harness that
338
393
  * summarised them would be the same defect with a smaller radius.
394
+ *
395
+ * 🚨 With one exception, which is not the editor's words but the licence
396
+ * holder's (issue #1082): the licence line, which `licenceLines` names and
397
+ * which is replaced by one line saying it was withheld. Every other line is
398
+ * quoted as printed, in order.
339
399
  */
340
400
  function editorSaid(ran: Ran): string[] {
341
401
  const lines: string[] = [];
@@ -346,7 +406,21 @@ function editorSaid(ran: Ran): string[] {
346
406
  const body = text.replace(/\s+$/, '');
347
407
  if (body === '') continue;
348
408
  lines.push(` the editor's ${stream}:`);
349
- for (const line of body.split('\n')) lines.push(` | ${line}`);
409
+ const printed = body.split('\n');
410
+ const withheld = licenceLines(printed);
411
+ for (let i = 0; i < printed.length; i++) {
412
+ if (!withheld.has(i)) {
413
+ lines.push(` | ${printed[i]}`);
414
+ continue;
415
+ }
416
+ // One note per run of withheld lines, in the place they were printed, so
417
+ // the quotation still reads in the editor's order and the omission is
418
+ // stated rather than left to look like an editor that said less.
419
+ let span = 1;
420
+ while (withheld.has(i + span)) span++;
421
+ lines.push(` ~ ${span} line(s) withheld here: the editor's licence block names the licence holder (issue #1082)`);
422
+ i += span - 1;
423
+ }
350
424
  }
351
425
  // Silence is a finding too, and it has to be stated rather than left to look
352
426
  // like a harness that forgot to print. The card above is what an unstated one