spine-rigc 0.34.0 → 0.35.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
@@ -510,7 +510,8 @@ resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, a
510
510
 
511
511
  **What it will not do is invent.** Everything the spec format cannot hold is a
512
512
  finding with a code — `BLOCK` for a construct the rebuild will be missing, `JUDGE`
513
- for the two values a skeleton does not carry, `LOSS` wherever the source's spelling and
513
+ for what a skeleton does not carry and the rebuild has to state (the two values
514
+ below, and whether a muted constraint is the consumer's), `LOSS` wherever the source's spelling and
514
515
  rigc's differ on purpose (a number rigc re-derives, a field the spec has no home for, or
515
516
  a default the source left to the format and the rebuild writes out). A blocker exits
516
517
  non-zero and still writes both files. **Every code it can print has a row at the end
@@ -560,6 +561,23 @@ at the policy rather than implying the file was read.
560
561
  is used, stated in the motion spec's `note`, and recorded as a finding per
561
562
  animation. Edit it if you know the real number.
562
563
 
564
+ 🎛️ **And one statement is not in a skeleton either: who turns a muted constraint on**
565
+ ([#784](https://github.com/firejune/rigc/issues/784)). An ik or transform constraint
566
+ resting at 0 on every mix it reads, that no animation keys above 0, is either a
567
+ leftover that moves nothing or a dial a game sets from code — and the two export as
568
+ the same bytes. `build` refuses the shape by name (`A47`/`A48`), which is how a
569
+ production skeleton's rebuild was refused over an ik its game switches on at runtime.
570
+ So `ingest` reads it the way under which the file is correct: it writes the
571
+ constraint into the rig spec's `invariants.consumerDrivenMix` ([AUTHORING
572
+ §3.7](AUTHORING.md)), with a `why` saying the entry is `ingest`'s reading, and prints a
573
+ `CONSUMER_DRIVEN_MIX` **judgement** naming it. The rebuild is the same bytes — the
574
+ declaration is a statement to the gate, never emitted — and it gates green, with the
575
+ constraint SKIPped by name rather than measured. It is the only field of `invariants`
576
+ `ingest` ever writes, and the rig spec's `note` says so where it is present.
577
+ ⚠️ **None of the twelve exports carries the shape**: every constraint they rest muted
578
+ is keyed up by an animation, spineboy's aim rig being the idiom, and the corpus half
579
+ of the `IG` suite holds that no line and no declaration appear on any of them.
580
+
563
581
  And two flags for what the skeleton also does not encode: `--art loose` (the default)
564
582
  names an `image` per attachment resolved against loose PNGs, `--art none` states
565
583
  `width`/`height` for `build --atlas-in` — and for `explain --atlas-in`, which is the
@@ -609,12 +627,13 @@ is the one failure a comparison of two sets cannot show you.
609
627
  | `BONE_FIELD` | `BLOCK` | 1 | a bone field with no rig-spec field, so it is dropped. A 4.0/4.1 export spelling `transform` where 4.3 spells `inherit` lands here; so does a misspelling | check the name against AUTHORING §3 first — a typo and an unsupported field read exactly the same |
610
628
  | `BONE_TIMELINE` | `BLOCK` | 1 | a bone timeline the motion spec has no track for. The detail names the eleven it has, read off the table. Since [#733](https://github.com/firejune/rigc/issues/733) carried `inherit` — the eleventh case of the runtime's own bone switch, a stepped mode per key — every bone timeline the runtime plays has a track, so this is reachable only for a name the **parser** throws on too (`Invalid timeline type for a bone`), the position `PHYSICS_TIMELINE` is in | check the spelling; there is no bone timeline left for the rebuild to be missing |
611
629
  | `CONSTRAINT_FIELD` | `BLOCK` | 1 | as `BONE_FIELD`, on a constraint, with its type named beside it | as `BONE_FIELD` |
612
- | `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 |
630
+ | `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 of the spec at the value the parser would have read there — and the line is printed only for a value the **file** will still carry. Since [#716](https://github.com/firejune/rigc/issues/716) the emitter leaves a value out wherever it is the one the parser reads without it ([AUTHORING §10.6c](AUTHORING.md)), so a restated default is the source's own text again and says nothing; what is left is a value the table has no row for, or a transform key's `mixY` the source left out beside a `mixX` that is not 1, which the emitter keeps because that is where the editor writes it (§10.6c's *only at 1*). Measured on the twelve exports: 20 tracks printed this before #716, 0 after | nothing. Same values, and where this prints, a larger file — the rebuild plays what the source plays |
631
+ | `CONSUMER_DRIVEN_MIX` | `JUDGE` | 0 | an `ik` or `transform` constraint resting at 0 on every mix it reads — an ik's `mix`; a transform's mixes for the `to` properties it declares — that no animation keys above 0, reading every key the way `A47`/`A48` do: an omitted mix is the parser's 1 and a Bezier handle above 0 lifts a 0 → 0 pair. Nothing in the file ever switches it on, and the file cannot say whether that is a leftover or a mix a game sets from code, so the rig spec **declares** it in `invariants.consumerDrivenMix` ([#784](https://github.com/firejune/rigc/issues/784)) and the rebuild's gate SKIPs it by name. It is a judgement for `DURATION`'s reason: a statement the skeleton does not carry, made and printed — and not a `LOSS`, because the rebuilt skeleton is the source's bytes. A transform that declares no `to` at all is not a candidate: `A48` refuses it with its own sentence, which no declaration answers | nothing, if a game drives that mix. If it is a leftover, delete the entry and rest a mix it reads above 0 — or remove the constraint — and the gate measures it again |
613
632
  | `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 |
614
633
  | `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 |
615
634
  | `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 |
616
635
  | `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 |
617
- | `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 |
636
+ | `HEADER_BOOKKEEPING` | `LOSS` | 0 | a header field the editor writes and the rig spec has no home for — `hash`, the editor's project hash, which is a value about a file rigc did not write. Dropped, and nothing reads it back. `audio` was on this line until [#716](https://github.com/firejune/rigc/issues/716): the rig spec states it now ([AUTHORING §3.1](AUTHORING.md)) and `ingest` carries it, `null` included | nothing. It is one of the two declared exceptions §2.3's pass line is stated apart from |
618
637
  | `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 |
619
638
  | `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 |
620
639
  | `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 |
@@ -629,7 +648,7 @@ is the one failure a comparison of two sets cannot show you.
629
648
  | `SLOT_TIMELINE` | `BLOCK` | 1 | a slot timeline the motion spec has no track for. Since [#730](https://github.com/firejune/rigc/issues/730) carried `rgb`, `alpha` and `rgb2` the spec has a track for all six the format has, so what still reaches this line is a name **outside** the format — `sequence` written on a slot rather than an attachment is the likeliest — and the detail says so: the runtime's own reader throws `Invalid timeline type for a slot` on it, so no player loads that file either. The detail names the tracks the spec does have and, when the format has any it lacks, those too, **both read off the tables** rather than listed here: this cell named `rgba2` among the timelines nobody carries until [#690](https://github.com/firejune/rigc/issues/690) made that false, which is what a hand-kept list beside a derived one always comes to. `rgb` and `alpha` are carried under their own names and on their own key times, never folded into one `rgba` — that would state each channel at the other's key times, a value nobody keyed | fix the timeline's name, or accept that the rebuild plays nothing there |
630
649
  | `SPEC_REFUSED` | `BLOCK` | 1 | the specs were written and **rigc's own parser refuses one of them** — the detail carries that refusal word for word, after the file and the spec it is about. It is the one finding that is not about a single construct: it is whatever `parseRigSpec` or `parseMotionSpec` names, from a shape the format holds and the spec cannot say (a constraint that is `skinRequired` under no skin) to a defect in this decompiler. Until [#692](https://github.com/firejune/rigc/issues/692) the refusal left through `ingest` itself, so the run exited 1 with no line, no code and no `findings.json` at all | read the quoted sentence against the skeleton: it names the object. Both specs are on disk for exactly that, and `build` will refuse them until the shape has a spelling — [AUTHORING §5.1](AUTHORING.md) is the list of what a parser says |
631
650
  | `TIMELINE_FIELD` | `BLOCK` | 1 | a key field on a bone, path, physics or slider timeline that is not part of that timeline's shape. On an `inherit` key that includes a `curve`: the parser reads `time` and `inherit` there and nothing else, and `build` refuses a curve on that track by name | check the spelling; the field is dropped from the rebuilt key |
632
- | `TIMELINE_KEY_RESTATED` | `LOSS` | 0 | **the commonest line in a real run.** An editor omits a channel that equals the parser's default; the motion spec's `v` is positional, so the omission is written out at that default. On an `inherit` key it is the mode: one that omits it is written as `normal` — the parser's default — and one spelled with a capital first letter (`NoScale`) as the editor's `noScale`, the same mode either way | nothing. The same values the runtime reads, spelled out — a larger file and the same animation |
651
+ | `TIMELINE_KEY_RESTATED` | `LOSS` | 0 | an editor omits a channel that equals the parser's default, and the motion spec's `v` is positional, so the omission is written out at that default **in the spec** — and the line is printed only where the **file** will carry it too. It was the commonest line in a real run until [#716](https://github.com/firejune/rigc/issues/716): the emitter now leaves a channel out wherever it is the one the parser reads without it ([AUTHORING §10.6c](AUTHORING.md)), so on every key kind with a row the rebuild is the source's own text and nothing is said — measured on the twelve exports, 341 tracks printed it before and 0 after. What still prints it is a timeline whose keys have no row (`shearx`, `alpha`, path `spacing`, …), and an `inherit` key: one that omits the mode is written as `normal` — the parser's default — and one spelled with a capital first letter (`NoScale`) as the editor's `noScale`, the same mode either way | nothing. The same values the runtime reads, spelled out — a larger file and the same animation |
633
652
  | `TRANSFORM_KEY_FIELD` | `BLOCK` | 1 | as `IK_KEY_FIELD`, on a `transform` timeline | as `IK_KEY_FIELD` |
634
653
 
635
654
  ⚠️ **One thing the table cannot carry: the region key is kept where a placeholder is
@@ -750,24 +769,45 @@ motion you are *authoring* — this is the one case the escape hatch exists for,
750
769
  ### 2.3 What "byte-identical" can and cannot mean
751
770
 
752
771
  State the ambition in the right units, because three different things get called
753
- "identical" and only two are reachable.
772
+ "identical", and the third is reachable only in a form worth stating exactly.
754
773
 
755
774
  | Ambition | Reachable? | What it costs, and what it proves |
756
775
  | --- | --- | --- |
757
776
  | **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 |
758
777
  | **Geometric agreement** — the same drawn pixels, allowing for the atlas | ✅ yes, and `check` measures it | see below |
759
- | **Byte-identical JSON** | ⛔ **no, and not because of the geometry** | rigc writes defaults explicitly where the editor omits them, and the editor writes bookkeeping rigc has no field for. SPEC_COVERAGE records the count on rung 6: a field-by-field comparison against the reference export leaves **49 differences, every one benign** — 39 explicit defaults, 3 editor bookkeeping keys, 1 runtime version string, and 6 bone `icon` values, which was the only thing the rig spec could not say at all |
778
+ | **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: SPEC_COVERAGE's rung-6 count — 49 benign differences, 39 of them explicit defaults — was taken before [#716](https://github.com/firejune/rigc/issues/716) made the emitter leave a default out, and it is a count about what a person chose to write, not about the emitter |
779
+
780
+ ⭐ **The pass line of the byte round trip, stated once:** `build(ingest(x))` of an
781
+ editor export `x` is **identical to `x` in canonical form, apart from `hash` and
782
+ `spine`** — canonical form being `JSON.stringify(JSON.parse(text), null, 2)` of each
783
+ file, which keeps every number as parsed, every key in its order and every omitted key
784
+ omitted, and drops only whitespace and the exponent's spelling (an export setting, not
785
+ a property of the rig). `IG83` holds it on all twelve exports under `examples/` every
786
+ run and prints the first differing path when it does not hold; for a skeleton **rigc**
787
+ emitted, `build(ingest(x))` is byte-identical outright (`IG63`, `IG71`).
760
788
 
761
- ⚠️ **The third row holds for `ingest` too, and it is worth knowing in which direction.**
762
- `build(ingest(x))` is byte-identical for a skeleton **rigc** emitted — that is the
763
- contract `bun run selftest` gates on every run — and it is not, for a skeleton the
764
- editor emitted. Measured over all twelve corpus exports, a field-by-field walk of the
765
- rebuild against its source produces differences of exactly two kinds, in every file:
789
+ The two **declared exceptions** are the header keys the editor writes and the rig spec
790
+ has no field for, by design, and each has its finding:
766
791
 
767
- | Kind | Example, candidate vs reference | Why |
792
+ | Header key | Example, rebuild vs export | Why it is an exception |
768
793
  | --- | --- | --- |
769
- | **header bookkeeping**, 3 per file | `skeleton.hash: undefined vs "VFWbaK2UoCM"`, `skeleton.audio: undefined vs null`, `skeleton.spine: "4.3.13" vs "4.3.75-beta"` | the rig spec has no field for `hash` or `audio`, and the version is the runtime rigc links. `ingest` reports all three as findings — `HEADER_BOOKKEEPING` and `HEADER_REDERIVED` |
770
- | **an omitted default written out** | `…rotate[0].time: 0 vs undefined` | the editor omits a zero `time`; rigc writes it. AUTHORING §10.5's *do not imitate the exporter's omissions*, from the other side. The header has one of these too and it is the one `ingest` now names: a stage at the origin is written `width`/`height` with no `x`/`y`, and the rebuild spells both — `LOSS HEADER_ORIGIN`, with the box unchanged ([#622](https://github.com/firejune/rigc/issues/622)). No file in this corpus takes that branch: all twelve declare an origin away from 0 |
794
+ | `hash` | `undefined` vs `"VFWbaK2UoCM"` | the editor's project hash — a value about a file rigc did not write, so a spec that carried it would be claiming an export it did not come from. `HEADER_BOOKKEEPING` |
795
+ | `spine` | `"4.3.13"` vs `"4.3.75-beta"` | the version of the runtime rigc links, stamped by design and re-checked by `A16`. `HEADER_REDERIVED` |
796
+
797
+ 🔢 **Two more rows stood in a table here until the third tranche of #716 landed.**
798
+ The **header's `audio`** (`undefined` vs `null`) is a stated value like `images`, and
799
+ the rig spec now carries it ([AUTHORING §3.1](AUTHORING.md)). **An omitted default
800
+ written out** — `…rotate[0].time: 0 vs undefined`, 2,338 keys over the twelve and
801
+ 87 `"name": null` — was rigc writing every key it was given where the editor leaves
802
+ out what the parser reads the same way without it; the emitter now leaves out exactly
803
+ those ([AUTHORING §10.6c](AUTHORING.md)), and `IG81` counts 0 of either. One shape of
804
+ it is left and it is deliberate: a stage at the origin, which the editor writes as
805
+ `width`/`height` with no `x`/`y` and rigc spells whole. The 4.3 JSON reader has no
806
+ default for the origin — an absent one loads as `undefined`, a written `0` as `0` — so
807
+ it is not a key the parser reads the same way without it, and the row is not in the
808
+ table; `LOSS HEADER_ORIGIN` names it
809
+ ([#622](https://github.com/firejune/rigc/issues/622)). No file in this corpus takes that
810
+ branch: all twelve declare an origin away from 0.
771
811
 
772
812
  🔢 **A third row stood here until issue #716: the emitted precision** —
773
813
  `…curve[0]: 0.066667 vs 0.06666667`, `uvs[0]: 0 vs 2.554152e-7` — because rigc wrote
@@ -813,10 +853,10 @@ does **not** cover, in the same breath:
813
853
 
814
854
  | Still uncovered | Why |
815
855
  | --- | --- |
816
- | `version` and `hash` | the rig spec has no field for either, and `ingest` reports both as findings — the header row above, unchanged |
856
+ | `spine` and `hash` | the rig spec has no field for either, by design, and `ingest` reports both as findings — the declared exceptions above |
817
857
  | anything below one float32 step | the parser stores frames, curves and vertices in a `Float32Array`, so a difference it cannot represent is invisible to any reading of the parsed form |
818
858
  | a Bezier's handles *as written* | the parser samples them into the curve, so a moved handle arrives as moved samples rather than as the handle it was |
819
- | how the file is **spelled** | field order and an omitted default written out — the second row of the table above is values that agree, and this measure says so. A number's spelling is `IG73`'s, which reads the rebuild as text |
859
+ | how the file is **spelled** | nothing, on an editor export, but the declared exceptions: since [#716](https://github.com/firejune/rigc/issues/716) the rebuild spells every number as the export does (`IG73`), writes every object's keys in the export's order (`IG76`, [AUTHORING §10.6b](AUTHORING.md)) and leaves out every key the export leaves to the parser (`IG81`, [AUTHORING §10.6c](AUTHORING.md)), and `IG83` reads the whole text. This row stays because the value measure still cannot see spelling — it compares what the parser loaded — and the pass line above is what can |
820
860
  | how it **looks** | that is `check`, and `--texture-from` is how its figure is attributed |
821
861
 
822
862
  The geometric row needs a real number, because a naive reading of `check` makes an
@@ -1001,6 +1041,14 @@ has three possible meanings and the message alone does not separate them:
1001
1041
  `A00` above);
1002
1042
  3. **the rule is stricter than the runtime** — fix the rule, or file it.
1003
1043
 
1044
+ 🎛️ **`A47` / `A48` on a foreign export are a fourth reading, and neither side is
1045
+ wrong.** An ik or transform resting muted that no animation keys up moves nothing *in
1046
+ the file*, and `validate <file>` has no rig spec and so no way to be told a game turns
1047
+ it on from code — it refuses with three doors, the third being that statement. The
1048
+ rebuild route makes it for you: `ingest` declares the constraint consumer-driven and
1049
+ prints a `CONSUMER_DRIVEN_MIX` judgement (§2.0), and `build` then SKIPs it by name
1050
+ ([#784](https://github.com/firejune/rigc/issues/784)).
1051
+
1004
1052
  ⇒ Before changing anybody's export because rigc objected, check case 3: does the file
1005
1053
  **parse** (`A00`), **step without NaN** (`A10`), and **render**? If all three, the
1006
1054
  runtime is content and the burden is on the rule. Reporting that is a better answer
@@ -743,7 +743,7 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
743
743
  | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | a transform constraint whose mixes for the properties it drives are all 0 at setup and on every value a key poses, or one that drives no property ([#765](https://github.com/firejune/rigc/issues/765)) |
744
744
  | `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
745
745
  | `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
746
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
746
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay part none of whose texels is below full alpha — refused unopened when the file has no alpha channel and no `tRNS` chunk, and by its texels otherwise, on a loose page and a shared one alike |
747
747
  | `A20_MESH_WEIGHTS_COHERENT` | **renderer-profile / archetype** | **unweighted meshes**, weights not summing to 1, out-of-range bone indices |
748
748
  | `A21_MESH_RIM_PINNED` | archetype | ring/ribbon/contour rim not pinned |
749
749
  | `A22_MESH_UVS_IN_UNIT_RANGE` | validity-ish | UVs outside 0..1 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.34.0",
3
+ "version": "0.35.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/compile.ts CHANGED
@@ -39,6 +39,7 @@ import { parseJsonWithPosition } from './json-position.ts';
39
39
  // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
41
  import { nearMisses } from './keys.ts';
42
+ import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.ts';
42
43
  import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
43
44
  import {
44
45
  BONE_INHERIT_KNOWN,
@@ -377,6 +378,51 @@ function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
377
378
  return [...pinned, ...[...rest].sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))];
378
379
  }
379
380
 
381
+ /**
382
+ * A skin's `attachments` map keyed in the editor's order of its SLOT names —
383
+ * the comparator `animations` and `skins` use — rather than the draw order the
384
+ * table is built in (issue #716).
385
+ *
386
+ * 🔬 Measured twice, from both directions. The editor's export of a rigc build
387
+ * (`gallery/look`, 4.3.26) returned a skin's 24 slot keys sorted (§10.1 of the
388
+ * guide), and over the twelve exports under `examples/` every skin's slot keys
389
+ * are in this comparator's order — 12 of 12, `spineboy-pro`'s
390
+ * `portal-flare9, portal-flare10` among them, which codepoint would reverse.
391
+ * rigc wrote draw order, and 11 of the twelve rebuilds keyed a skin differently
392
+ * from the export they were rebuilt from.
393
+ *
394
+ * ⚠️ **Unlike `skins` and `animations`, nothing here is refused.** Those two are
395
+ * ordinals in the binary half, so an order rigc cannot certify is a reference it
396
+ * cannot certify. A slot key is read by name (`SkeletonJson` resolves each one
397
+ * with `findSlot`) and is no reference at all, so an order the comparator leaves
398
+ * open is a text rigc cannot promise, not a rig that is wrong — and a key's
399
+ * position is never a verdict. Such a map keeps rigc's order WHOLE, never
400
+ * partly sorted:
401
+ *
402
+ * - a pair the comparator leaves open (`editorNameOrder`'s number, separator and
403
+ * folder cases);
404
+ * - any slot name holding `/`. The folder rule was measured on skin and
405
+ * animation names, which the editor files in folders; slots it files under
406
+ * bones, and no round trip carried a slot name with a `/` in it.
407
+ *
408
+ * The per-slot maps inside are left as built: their order is the rig's own, the
409
+ * twelve rebuilds already match the exports there (0 of 141 differ), and an
410
+ * attachment name holding `/` is the common case the folder question would
411
+ * have to answer first.
412
+ */
413
+ function editorSlotKeyOrder<T>(attachments: Record<string, T>): Record<string, T> {
414
+ const names = Object.keys(attachments);
415
+ if (names.some((name) => name.includes(NAME_FOLDER))) return attachments;
416
+ for (let i = 0; i < names.length; i++) {
417
+ for (let j = i + 1; j < names.length; j++) {
418
+ if (typeof editorNameOrder(names[i], names[j]) !== 'number') return attachments;
419
+ }
420
+ }
421
+ const ordered: Record<string, T> = {};
422
+ for (const name of [...names].sort((a, b) => editorNameOrder(a, b) as number)) ordered[name] = attachments[name];
423
+ return ordered;
424
+ }
425
+
380
426
  /** What separates a folder from what it holds, in a skin or an animation name. */
381
427
  const NAME_FOLDER = '/';
382
428
 
@@ -3172,6 +3218,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3172
3218
  if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
3173
3219
  const imagesPath = skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs);
3174
3220
  if (imagesPath !== undefined) header.images = imagesPath;
3221
+ if (rig.skeleton?.audio !== undefined) header.audio = rig.skeleton.audio;
3175
3222
 
3176
3223
  // Event definitions. Emitted in the order the rig spec declares them — object
3177
3224
  // key order is the spec's, not a set's, so A18 stays a contract.
@@ -3213,7 +3260,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3213
3260
  parts!.constraints[key],
3214
3261
  ]),
3215
3262
  ),
3216
- attachments,
3263
+ attachments: editorSlotKeyOrder(attachments),
3217
3264
  };
3218
3265
  }),
3219
3266
  ),
@@ -3235,6 +3282,23 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3235
3282
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
3236
3283
  }
3237
3284
 
3285
+ // Every object's keys in the order the editor writes them, per kind, from the
3286
+ // one table that says so (`src/keyorder.ts`, issue #716) — applied here, once,
3287
+ // to the finished object and before the text exists, so the constructors above
3288
+ // are free to build in whatever order reads best and none of them states the
3289
+ // order a second time. It moves positions and nothing else: no key is added,
3290
+ // dropped or re-valued, and what the gate and `A18` read is this object's text.
3291
+ //
3292
+ // Before it, and on the same finished object: every key whose value is the
3293
+ // one the 4.3 parser reads in its absence is left out, the way the editor
3294
+ // leaves it out (issue #716 tranche 3). One pass beside the other rather than
3295
+ // folded into it, because each has its own table and the selftest plants a
3296
+ // wrong row into each on its own; both walk the object through
3297
+ // `forEachKindedObject`, so what a kind is stays said in one place. The spec
3298
+ // is untouched — an author may still write `x: 0`, and it is still the record
3299
+ // of what was written — and what the parser loads does not move (`S103`).
3300
+ inEditorKeyOrder(withoutParserDefaults(skeleton));
3301
+
3238
3302
  return {
3239
3303
  skeleton,
3240
3304
  skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
@@ -5590,6 +5654,10 @@ function buildContourAttachment(
5590
5654
  tolerance: generator.tolerance,
5591
5655
  margin,
5592
5656
  maxVertices,
5657
+ // The spec's two distances are the drawing's pixels; on a `scale:` page
5658
+ // the trace runs on texels, and the STATED scale is what converts them
5659
+ // (issue #779). Off one, `atlasScale` is absent and nothing is multiplied.
5660
+ ...(img.atlasScale === undefined ? {} : { pageScale: img.atlasScale }),
5593
5661
  });
5594
5662
  } catch (err) {
5595
5663
  if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
@@ -5652,8 +5720,8 @@ function buildContourAttachment(
5652
5720
  bones: [ctx.anchorBone],
5653
5721
  coverage: geometry.contour?.coverage,
5654
5722
  // In the drawing's pixels, like the authored fit's (issue #762). The trace
5655
- // itself — margin, tolerance, the refusals inside `buildContourMesh` — runs
5656
- // on the plate's grid and speaks in it; only the reported distance moves.
5723
+ // runs on the plate's grid with the spec's margin and tolerance converted
5724
+ // onto it by the stated scale (issue #779), and its refusals state both.
5657
5725
  ...(geometry.contour === undefined ? {} : drawingOvershoot(geometry.contour.overshoot, img.atlasScale)),
5658
5726
  holePixels: geometry.contour?.holePixels,
5659
5727
  depth: depth?.summary,
@@ -6367,6 +6435,8 @@ function buildRigInfo(
6367
6435
  meshSoftBones,
6368
6436
  deformMayFold,
6369
6437
  editorRoundTrip: rig.invariants?.editorRoundTrip === true,
6438
+ // `parseRigSpec` resolved every entry against the rig's own constraints.
6439
+ consumerDrivenMix: (rig.invariants?.consumerDrivenMix ?? []).map((e) => ({ type: e.type, constraint: e.constraint, why: e.why })),
6370
6440
  // `isBase` is set on the manifest route only; every other image says false.
6371
6441
  basePlates: images.filter((img) => img.isBase).map((img) => img.region),
6372
6442
  meshSlotBudget: rig.invariants?.meshSlots ?? null,