spine-rigc 0.20.2 → 0.20.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/docs/AUTHORING.md CHANGED
@@ -564,6 +564,37 @@ behind it writes literal `x`/`y` instead.
564
564
 
565
565
  **R9 — Nothing is written until every assertion is green.**
566
566
 
567
+ **R10 — The `animations` object is keyed in the editor's order, not in yours, and
568
+ names that have no one order are refused.** Declare animations in whatever order
569
+ reads best; the emit keys them codepoint-ascending. This is the one place rigc
570
+ reorders anything you wrote, and it is not cosmetic: a `slider`'s animation is a
571
+ **name** in JSON and an **ordinal** in the format's binary half, so an editor that
572
+ re-sorts the object repoints every slider whose animation moved index — silently,
573
+ in a file that still parses and still gates green (§3.5.2,
574
+ [#535](https://github.com/firejune/rigc/issues/535)). Nothing else moves: each
575
+ animation's own body is byte-identical either way, and every other collection is
576
+ emitted in the order you gave it.
577
+
578
+ ⚠️ **Codepoint is not the editor's comparator.** The editor sorts **natural and
579
+ case-insensitive** — measured, two rigs, one axis each:
580
+ `Turn, sweep, wave` came back `sweep, Turn, wave`, and `turn10, turn2, zoom` came
581
+ back `turn2, turn10, zoom` ([#539](https://github.com/firejune/rigc/issues/539)).
582
+ Codepoint agrees with it on most names and not on all, so rigc emits codepoint and
583
+ **refuses the sets where the two could differ**, naming the pair. The rule you have
584
+ to hold is therefore about *names*, and it is three things:
585
+
586
+ | Do not let two animation names differ | Because | Instead |
587
+ | --- | --- | --- |
588
+ | by **case** at the character that orders them (`Turn` against `sweep`, or `Turn` against `turn`) | folding the case reverses them, and a pure case tie is settled by a tie-break nobody has measured | pick one case for all of them, or change a letter |
589
+ | by a **number** read two ways (`turn2` against `turn10`, `turn01` against `turn1`, `1turn` against `turn`) | as text `turn10` sorts first and as a number it does not; `01` and `1` are one number written twice | pad the digits to the same width — `turn02` beside `turn10` |
590
+ | by a **separator** — anything that is neither a letter nor a digit (`wave_x` against `wavea`, `wave` against `wave-`) | a collator may treat `-` or a space as ignorable, and `_` sits *between* `Z` and `a`, so folding up and folding down order it oppositely | rename so the first character that differs is a letter or a digit |
591
+
592
+ ⭐ **Capitals and digits are not what is refused** — only pairs whose order turns
593
+ on them. `Sweep, Turn, Wave, Zoom02, Zoom10` builds: every comparator puts those
594
+ five in one order, so codepoint *is* the editor's order for them. A set with no
595
+ such pair is safe under **every** candidate comparator, which is why rigc does not
596
+ have to reproduce the editor's sort to know your rig is safe under it.
597
+
567
598
  ---
568
599
 
569
600
  ## 3. The rig spec, field by field
@@ -1442,6 +1473,35 @@ parser resolves it in a **second pass** over the constraints array, after the
1442
1473
  animations are read, and a miss throws `Slider animation not found`; rigc refuses it
1443
1474
  where the message can name both files.
1444
1475
 
1476
+ 🔒 **And it is the one field an editor round trip can repoint under you, which is
1477
+ why R10 exists.** In JSON the reference is a name on both sides. In the format's
1478
+ binary half it is an **ordinal** — `constraint.animation = animations[readInt()]`
1479
+ (`SkeletonBinary`) — so an editor holding that ordinal writes back whichever
1480
+ animation now stands at the position. `gallery/look` went into a licensed editor
1481
+ (data version 4.3.26) as `turn, tilt, sweep` with `yaw -> "turn"` and came back
1482
+ `sweep, tilt, turn` with **`yaw -> "sweep"`**: a file that parses, gates green and
1483
+ applies the wrong animation. ⭐ Its second slider is what named the mechanism
1484
+ rather than a second casualty — `tilt` survived because it sat at index 1 in both
1485
+ orderings. rigc now emits animations codepoint-ascending so the editor's re-sort
1486
+ moves no index ([#535](https://github.com/firejune/rigc/issues/535)); on the same
1487
+ rig through the same editor that restored `yaw -> "turn"` and took the
1488
+ re-rendered mean absolute error from 10.4655 / 8.4961 / 8.7140 down to
1489
+ 0.3035 / 0.0769 / 0.0588.
1490
+
1491
+ ⚠️ Codepoint is not the editor's own comparator — it sorts natural and
1492
+ case-insensitive ([#539](https://github.com/firejune/rigc/issues/539)) — so the
1493
+ emit is only its order for names no comparator can put two ways, and the rest are
1494
+ a compile error. **R10** has the three shapes to avoid.
1495
+
1496
+ ⚠️ **What that repair does not reach: names a codepoint sort and a friendlier one
1497
+ disagree about.** Every animation name in every editor-authored file this
1498
+ repository has is lowercase ASCII with `-` or `_`, so nothing measured here
1499
+ separates codepoint order from a case-insensitive or digit-aware one. Names
1500
+ differing only in case (`Turn` / `turn`) or carrying unpadded digits (`turn2` /
1501
+ `turn10`) are where the two could part, and there the hazard returns. Until
1502
+ somebody round-trips such a pair, **name animations so that every ordering anyone
1503
+ might use agrees** — one case, and digits padded or absent.
1504
+
1445
1505
  ⚠️ **The fields of the model you did not choose are refused, not ignored.** The
1446
1506
  parser reads `time` only in the bone-less branch and `property`/`from`/`to`/`scale`/
1447
1507
  `max`/`local` only in the other, so the losing half would be data no runtime ever
@@ -3273,6 +3333,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3273
3333
  | `skin "S": uses the long form … and also has a key "X"` | §3.4.1 — move the slot inside `attachments` |
3274
3334
  | `animation "A" keys "X" as a path constraint, but the rig declares it as a "slider"` | §4.12 — a timeline group resolves by name AND type; use the field named after the constraint's own type |
3275
3335
  | `animation "A": "position" is a path constraint timeline, and this track names no constraint` | §4.12 — put the name in `"path"` |
3336
+ | `N pair(s) of animation names have no one order: … "Turn" / "sweep" (case) — codepoint puts "Turn" first only because of letter case; folded, "sweep" comes first; rename one of them so nothing but case has to be compared` | **R10** — rename until no pair is left. The kind in brackets is which of the three it is: `case`, `number` (pad the digit runs to the same width) or `separator` (make the first character that differs a letter or a digit). rigc keys `animations` codepoint-ascending and the editor sorts natural and case-insensitive ([#539](https://github.com/firejune/rigc/issues/539)); on names where those can disagree, the editor's re-key repoints every slider whose animation moves index ([#535](https://github.com/firejune/rigc/issues/535)) |
3276
3337
 
3277
3338
  ### 5.2 Assertions — the gate
3278
3339
 
@@ -4817,6 +4878,39 @@ low figure as a miss — say in the log that the art did not carry them.
4817
4878
  `default`"* and *"bones are ordered so that the parent always comes before a child
4818
4879
  bone"* — [JSON format](http://esotericsoftware.com/spine-json-format). §3.4.
4819
4880
 
4881
+ 🔬 **The editor re-keys every name-keyed OBJECT and leaves every ARRAY alone.**
4882
+ Read off its export of a rigc build (Spine 4.3.26, `gallery/look`): the
4883
+ `animations` object, a skin's 24 `attachments` slot keys and two animations' 16
4884
+ and 2 bone-timeline keys all came back sorted, while the 30 `bones`, 24 `slots`
4885
+ and 3 `constraints` — arrays — came back in the build's own order, element for
4886
+ element, and each slider kept its place among them.
4887
+
4888
+ ⚠️ **The order it sorts them into is natural and case-insensitive, not
4889
+ codepoint.** This paragraph said codepoint until
4890
+ [#539](https://github.com/firejune/rigc/issues/539) measured it: `Turn, sweep,
4891
+ wave` came back `sweep, Turn, wave` and `turn10, turn2, zoom` came back
4892
+ `turn2, turn10, zoom`. The corpus says the same thing and always did — of its 105
4893
+ name-keyed collections, 102 are consistent with a codepoint sort and **3 are
4894
+ not**: `spineboy-pro.json` keys `portal-flare9` *before* `portal-flare10`, which
4895
+ no codepoint sort produces. Every natural comparator reproduces all 105. (The
4896
+ population is every object the format keys by a name and that carries more than
4897
+ one key, deform blocks counted at each of their three levels;
4898
+ [`src/compile.ts`](../src/compile.ts) states it in full beside
4899
+ `editorAnimationOrder`, so the count can be re-taken rather than trusted.)
4900
+ ⇒ in rigc: only `animations` is emitted sorted (R10), because it is the one
4901
+ object measured here whose ORDER is also an index space — every reference into
4902
+ the re-sorted *other* objects is by name on both sides, so nothing moves when
4903
+ they are re-keyed. rigc emits **codepoint** and refuses the name sets on which
4904
+ codepoint and the editor's comparator could differ, rather than reproducing a
4905
+ comparator whose leading-zero, case-tie, digit-against-word and separator
4906
+ behaviour is still unmeasured.
4907
+
4908
+ ✅ **`events` is re-keyed too, and the references into it survive it.** The same
4909
+ session measured it: `zebra, mike, alpha` came back `alpha, mike, zebra`, and the
4910
+ firings still resolved **by name** — `0.3 -> mike`, `0.6 -> alpha`, payloads
4911
+ intact (#539). So the editor treats `events` and `animations` differently, and
4912
+ rigc emits events in the order you declare them.
4913
+
4820
4914
  ### 10.2 Draw order
4821
4915
 
4822
4916
  📗 **An overlap change is a draw-order key.** The draw order *"can be keyed"*, and
package/docs/FACE.md CHANGED
@@ -1180,13 +1180,38 @@ breaks the moment the two share a target — in the worked example both `turn` a
1180
1180
  `tilt` key `headroll`. §7's paragraph on sliders is the mechanism and
1181
1181
  `A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET` is the refusal.
1182
1182
 
1183
- ⚠️ **What none of this measures: the Spine editor.** No editor export in this
1184
- repository carries a slider, so whether the editor preserves two of them, their
1185
- `additive` and `local` flags, and their order in the constraints array is
1186
- **unknown**. `tools/editor_roundtrip.ts` on a machine with a licensed editor is
1187
- what would answer it, and until somebody runs it the editor half of a parameter
1188
- axis is untested. The runtime half is not: every figure above came back through
1189
- `spine-core`.
1183
+ ✅ **The editor half, measured.** This paragraph said *unknown* until the round
1184
+ trip was taken with `tools/editor_roundtrip.ts` on a licensed editor (data
1185
+ version 4.3.26) against a 4.3.13 build of this worked example. What it found:
1186
+
1187
+ - **Both sliders come back, and the parameter axis survives.** `additive`,
1188
+ `local`, `bone`, `property`, `from`, `max` and `scale` are identical field for
1189
+ field, and the two keep their places in the `constraints` array. `mix: 1` and
1190
+ `to: 0` are dropped, and those are the format's own defaults (`SkeletonJson`
1191
+ reads `mix` as 1 and `to` as 0 when absent) — an elision, not a loss.
1192
+ - ⚠️ **The animation each slider *names* did not come back, until rigc changed
1193
+ what it emits.** The editor re-sorts the `animations` object and a slider's
1194
+ animation is an ordinal in the format, so `yaw -> "turn"` returned as
1195
+ `yaw -> "sweep"` — the first animation of the sorted list
1196
+ ([#535](https://github.com/firejune/rigc/issues/535)). rigc now emits
1197
+ animations codepoint-ascending; on the same rig through the same editor that
1198
+ restored `yaw -> "turn"` and took the re-rendered mean absolute error from
1199
+ 10.4655 / 8.4961 / 8.7140 (`sweep` / `tilt` / `turn`) to
1200
+ 0.3035 / 0.0769 / 0.0588, worst drift 16.535 px to 3.947 px.
1201
+ - 🚨 **The physics constraint on the cowlick comes back driving nothing.**
1202
+ `rotate: 1` is absent from the export, and an absent `rotate` parses as **0**
1203
+ (`SkeletonJson`), so the returned file states *drives nothing* rather than
1204
+ omitting a default — which is why `A23_PHYSICS_CONSTRAINT_EFFECTIVE` refuses it
1205
+ by name. Independent of the ordering defect, and open as
1206
+ [#536](https://github.com/firejune/rigc/issues/536).
1207
+ - 🔸 Unexplained: `diff` reports `animations.curve_kinds` moved on **196 of 200**
1208
+ keys in every round trip taken, the clean one included. Visually small once the
1209
+ ordering is fixed — but it is 98% of the keys, and *small* is not *explained*.
1210
+
1211
+ ⚠️ **What it still does not measure:** a rig carrying more than one skin or more
1212
+ than one event, which is where the same shape — an ordinal into an object the
1213
+ editor re-keys — could bite next (AUTHORING §10.1). The runtime half was never in
1214
+ doubt: every figure above came back through `spine-core`.
1190
1215
 
1191
1216
  ---
1192
1217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
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
@@ -136,6 +136,283 @@ export const SPINE_VERSION = '4.3.13';
136
136
 
137
137
  const FRAME = 1 / 60;
138
138
 
139
+ /**
140
+ * The order the emitted `animations` object is keyed in: **codepoint-ascending
141
+ * by name**, which is the order the Spine editor writes it back out in.
142
+ *
143
+ * ## Why the emitter has an opinion about this at all
144
+ *
145
+ * A `slider` constraint names the animation it applies, and in JSON that is a
146
+ * name on both sides. The **binary** format is where the same reference is an
147
+ * ordinal — `SkeletonBinary.js`: `constraint.animation = animations[readInt()]`
148
+ * — and an editor whose own model holds that ordinal reads the name at import
149
+ * and writes back whatever now stands at that position. Round-tripped through a
150
+ * licensed editor (data version 4.3.26), `gallery/look` went in as
151
+ * `turn, tilt, sweep` with `yaw -> "turn"` and came back as `sweep, tilt, turn`
152
+ * with **`yaw -> "sweep"`** (issue #535). Nothing in the returned file says so;
153
+ * it parses, it validates, and it applies the wrong animation.
154
+ *
155
+ * ⭐ The second slider is the control that names the mechanism rather than a
156
+ * second victim: `tilt` survived because it sat at index 1 in *both* orderings
157
+ * and index 1 is called `tilt` in both.
158
+ *
159
+ * ⇒ Emitting in the editor's own order makes its re-sort a no-op, so no index
160
+ * moves and no reference is repointed. Measured on the same rig through the
161
+ * same editor: mean absolute error over the re-rendered frames fell from
162
+ * 10.4655 / 8.4961 / 8.7140 to 0.3035 / 0.0769 / 0.0588, and `yaw -> "turn"`
163
+ * came back intact.
164
+ *
165
+ * ## Codepoint is NOT the editor's comparator, and this is why it is still what
166
+ * is emitted
167
+ *
168
+ * ⚠️ This comment used to say that codepoint order "is what every editor-authored
169
+ * file on hand is in", and that sentence was false when it was written. The
170
+ * counterexample ships with the tests: `examples/spineboy/export/spineboy-pro.json`
171
+ * keys `portal-flare9` **before** `portal-flare10` — in its default skin's
172
+ * attachment map and in two of `portal`'s timeline maps — and no codepoint sort
173
+ * produces that.
174
+ *
175
+ * 🔢 The survey behind that, stated so it can be re-taken rather than believed:
176
+ * over the 12 skeletons in `examples/`, take **every object the format keys by a
177
+ * NAME that carries more than one key** — `animations` and `events`; each skin's
178
+ * `attachments` map and each per-slot map inside it; each animation's `bones`,
179
+ * `slots`, `ik`, `transform`, `path`, `physics`, `events`, and its deform
180
+ * `attachments` at **all three** of its levels (skin, then slot, then attachment
181
+ * name). Arrays are excluded because the editor leaves them alone. That is
182
+ * **105** collections, of which **102** are consistent with a codepoint sort and
183
+ * **3** are not; all 12 natural comparators and all 8 `Intl.Collator`
184
+ * configurations tried reproduce every one of the 105.
185
+ *
186
+ * ⚠️ The deform clause is the whole of what makes it 105 rather than 104, and it
187
+ * is one collection: `animations.hoverboard.attachments.default` in
188
+ * `spineboy-pro.json`, whose four keys are slot names. Counting the skin level
189
+ * of a deform block and stopping there needs an exception the sentence above
190
+ * cannot state — every level of it is keyed by a name, and the editor re-keys
191
+ * each one — so the rule is "every level", and that collection is in.
192
+ *
193
+ * The editor was then measured directly (issue #539). Two rigs, each varying one
194
+ * axis: `Turn, sweep, wave` came back `sweep, Turn, wave`, and
195
+ * `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
196
+ * one hypothesis — **natural order, case-insensitive**.
197
+ *
198
+ * ⇒ So why not sort that way? Because "natural, case-insensitive" is a family of
199
+ * comparators rather than one. Leading zeros (`turn01` against `turn1`), a pure
200
+ * case tie (`Turn` against `turn`), whether a digit run sorts before a word, and
201
+ * what a separator is worth are each a free choice, and **the editor's answers to
202
+ * them are not measured**. Writing a comparator means choosing all four, and a
203
+ * chosen-but-unmeasured comparator is exactly how #537 landed.
204
+ *
205
+ * 🔒 It is also the one claim in the emitter that no gate can see. spine-core
206
+ * reads back everything else rigc writes; it does not sort, so the emitted key
207
+ * order has no oracle behind it. What rigc *can* check, with no editor and no
208
+ * comparator, is much smaller and is the whole of what matters: whether the names
209
+ * in front of it are a set on which every candidate comparator agrees. On such a
210
+ * set codepoint **is** the editor's order, whatever the editor's comparator turns
211
+ * out to be — and on every other set the build stops with both names in the
212
+ * message. See `orderTurnsOnTheComparator`.
213
+ *
214
+ * Codepoint also stays locale-independent, which `A18` requires: `localeCompare`
215
+ * would make the emitted bytes a property of the machine.
216
+ */
217
+ function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
218
+ // One definition of "the order rigc emits", shared with the check below, so the
219
+ // two cannot drift into checking different things.
220
+ const names = Object.keys(animations).sort(codepoint);
221
+ refuseNamesTheEditorCouldKeyDifferently(names);
222
+ const ordered: Record<string, T> = {};
223
+ for (const name of names) ordered[name] = animations[name];
224
+ return ordered;
225
+ }
226
+
227
+ /**
228
+ * The characters whose relative order every candidate comparator agrees on: the
229
+ * digits and, once case is folded, the lower-case ASCII letters. Everything else
230
+ * — `-`, `_`, a space, an accented letter — is worth something different to a
231
+ * collator than to a codepoint compare, so a pair those decide is not settled.
232
+ */
233
+ const SETTLED_CHARS = /[0-9a-z]/;
234
+
235
+ /** Maximal runs of digits and of non-digits, which is what "natural" compares. */
236
+ const runsOf = (name: string): string[] => name.match(/\d+|\D+/g) ?? [];
237
+
238
+ const sign = (n: number): number => (n < 0 ? -1 : n > 0 ? 1 : 0);
239
+ const codepoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
240
+
241
+ /** What made a pair's order a matter of opinion, and what the author has to do. */
242
+ interface Ambiguity {
243
+ kind: 'case' | 'number' | 'separator';
244
+ because: string;
245
+ repair: string;
246
+ }
247
+
248
+ /**
249
+ * Whether these two names can be ordered two ways — `null` when every comparator
250
+ * the editor could be using puts them in the order rigc emits them in.
251
+ *
252
+ * ## The predicate, and why it is a certificate rather than a hazard list
253
+ *
254
+ * Issue #539 proposed checking for "two names differing only in case, or sharing
255
+ * a prefix followed by digit runs of unequal length". **That list misses the rig
256
+ * that produced the measurement**: `Turn` and `sweep` differ in much more than
257
+ * case and carry no digits, and they are the pair the editor reordered. A list of
258
+ * hazards is open-ended — one was missing the day it was written — so what is
259
+ * implemented is the other direction: a pair is refused unless the position that
260
+ * decides it is one every candidate comparator must read the same way.
261
+ *
262
+ * There are exactly three ways to lose that, and each is a free choice in some
263
+ * real comparator:
264
+ *
265
+ * - **case** — folding reverses them (`Turn` before `sweep` by codepoint, after it
266
+ * folded), or they fold together and only a tie-break separates them.
267
+ * - **number** — a digit run decides, and reading it as text disagrees with
268
+ * reading it as a number (`turn10` before `turn2`), or two runs are the same
269
+ * number written two ways (`turn01`, `turn1`), or a run meets a word and
270
+ * comparators differ on which sorts first.
271
+ * - **separator** — the deciding character is neither a letter nor a digit. This
272
+ * one covers two adversaries at once: a collator that treats `-` or a space as
273
+ * ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
274
+ * `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
275
+ * oppositely. Neither name needs a capital in it for that to bite.
276
+ *
277
+ * ## What it was tested against
278
+ *
279
+ * 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
280
+ * tie-breaks × digits-before-words/after) , 2 plain case-insensitive ones, and 8
281
+ * `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
282
+ * over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
283
+ * **10,948,860 pairs, zero escapes**, where an escape is a pair some comparator
284
+ * reorders that this predicate calls safe. Two earlier drafts did have escapes,
285
+ * and both are why a clause above exists: `ignorePunctuation` found `arc-tracker`
286
+ * against `arcs`, and a terminal digit run found `a0` against `a00`.
287
+ *
288
+ * ⭐ The two-sided result is on real data. Across the same 105 collections the
289
+ * survey above defines, the ones this predicate refuses are **exactly** the 3
290
+ * whose own key order a codepoint sort cannot reproduce — no false positive, no
291
+ * false negative, against names the editor itself wrote.
292
+ *
293
+ * ⚠️ It does over-refuse, and the direction is deliberate: `flare1` against
294
+ * `flare10` is safe under all 22 and refused anyway, because the rule is stated
295
+ * on digit-run width rather than on a comparison, and the repair that fixes the
296
+ * genuinely broken sibling (`flare9` against `flare10`) fixes both.
297
+ */
298
+ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
299
+ const caseRepair = 'rename one of them so nothing but case has to be compared';
300
+ const numberRepair = 'pad the digit runs to the same width, or rename so no number decides the order';
301
+ const separatorRepair = 'rename so the first character that differs is a letter or a digit';
302
+ const la = a.toLowerCase();
303
+ const lb = b.toLowerCase();
304
+ if (la === lb) {
305
+ return {
306
+ kind: 'case',
307
+ because: `they are one name in two cases, and which of them the editor puts first is not measured`,
308
+ repair: caseRepair,
309
+ };
310
+ }
311
+ let i = 0;
312
+ while (i < la.length && i < lb.length && la[i] === lb[i]) i++;
313
+ if (i === la.length || i === lb.length) {
314
+ const longer = la.length > lb.length ? a : b;
315
+ const rest = (la.length > lb.length ? la : lb).slice(i);
316
+ if (!SETTLED_CHARS.test(rest)) {
317
+ return {
318
+ kind: 'separator',
319
+ because:
320
+ `"${longer}" is the other name followed by ${JSON.stringify(rest)}, which a comparator that ignores ` +
321
+ 'punctuation reads as the same name',
322
+ repair: separatorRepair,
323
+ };
324
+ }
325
+ } else if (!SETTLED_CHARS.test(la[i]) || !SETTLED_CHARS.test(lb[i])) {
326
+ const deciding = SETTLED_CHARS.test(la[i]) ? lb[i] : la[i];
327
+ return {
328
+ kind: 'separator',
329
+ because:
330
+ `the first character that differs is ${JSON.stringify(deciding)}, which is neither a letter nor a digit, ` +
331
+ 'and what that is worth is a property of the comparator',
332
+ repair: separatorRepair,
333
+ };
334
+ }
335
+ if (sign(codepoint(a, b)) !== sign(codepoint(la, lb))) {
336
+ return {
337
+ kind: 'case',
338
+ because: `codepoint puts "${a}" first only because of letter case; folded, "${b}" comes first`,
339
+ repair: caseRepair,
340
+ };
341
+ }
342
+ const ra = runsOf(la);
343
+ const rb = runsOf(lb);
344
+ for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
345
+ const x = ra[k];
346
+ const y = rb[k];
347
+ if (x === y) continue;
348
+ const xIsDigits = /^\d/.test(x);
349
+ const yIsDigits = /^\d/.test(y);
350
+ if (xIsDigits && yIsDigits) {
351
+ if (BigInt(x) === BigInt(y)) {
352
+ return {
353
+ kind: 'number',
354
+ because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
355
+ repair: numberRepair,
356
+ };
357
+ }
358
+ if (x.length === y.length) return null;
359
+ return {
360
+ kind: 'number',
361
+ because:
362
+ `"${x}" and "${y}" are runs of digits of different widths, so reading them as text and reading them as ` +
363
+ `the numbers ${BigInt(x)} and ${BigInt(y)} can disagree`,
364
+ repair: numberRepair,
365
+ };
366
+ }
367
+ if (xIsDigits !== yIsDigits) {
368
+ return {
369
+ kind: 'number',
370
+ because:
371
+ `one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
372
+ 'differ on whether a number sorts before a word',
373
+ repair: numberRepair,
374
+ };
375
+ }
376
+ return null;
377
+ }
378
+ return null;
379
+ }
380
+
381
+ /** How many pairs a refusal spells out before it starts counting them instead. */
382
+ const PAIRS_SPELLED_OUT = 8;
383
+
384
+ /**
385
+ * Refuse a set of animation names the editor could key in an order rigc did not
386
+ * emit — the check that lets `editorAnimationOrder` sort by codepoint without
387
+ * claiming codepoint is the editor's rule.
388
+ *
389
+ * It is deliberately **not** conditional on the rig declaring a slider. A slider
390
+ * is what makes the difference bite today, but the emitted order is a claim about
391
+ * the editor either way, and a check that only ran when a slider was present would
392
+ * make the claim hold for some rigs and not others — with nothing saying which.
393
+ * Adding the slider is then the edit that refuses a rig that built yesterday.
394
+ */
395
+ function refuseNamesTheEditorCouldKeyDifferently(sorted: readonly string[]): void {
396
+ const found: string[] = [];
397
+ for (let i = 0; i < sorted.length; i++) {
398
+ for (let j = i + 1; j < sorted.length; j++) {
399
+ const pair = orderTurnsOnTheComparator(sorted[i], sorted[j]);
400
+ if (pair) found.push(`"${sorted[i]}" / "${sorted[j]}" (${pair.kind}) — ${pair.because}; ${pair.repair}`);
401
+ }
402
+ }
403
+ if (!found.length) return;
404
+ const spelled = found.slice(0, PAIRS_SPELLED_OUT);
405
+ throw new CompileError(
406
+ `${found.length} pair(s) of animation names have no one order: rigc keys the emitted "animations" object ` +
407
+ 'codepoint-ascending, which is the Spine editor\'s own order only for names whose order does not turn on ' +
408
+ 'case, on a number, or on a separator. The editor sorts natural and case-insensitive (#539), a slider\'s ' +
409
+ 'animation is an ORDINAL in the format\'s binary half, and an editor that keys these differently repoints ' +
410
+ 'every slider whose animation moves index — silently, in a file that still parses (#535). ' +
411
+ `${spelled.join('. ')}` +
412
+ (found.length > spelled.length ? `. …and ${found.length - spelled.length} more pair(s)` : ''),
413
+ );
414
+ }
415
+
139
416
  // ---------------------------------------------------------------------------
140
417
  // number formatting — deterministic, and free of "-0"
141
418
  // ---------------------------------------------------------------------------
@@ -1810,7 +2087,13 @@ export function compile(opts: CompileOptions): CompileResult {
1810
2087
  // conditional spread rather than an assignment after the literal, so the key
1811
2088
  // lands in that position instead of at the end.
1812
2089
  ...(Object.keys(events).length ? { events } : {}),
1813
- animations,
2090
+ // Keyed in the editor's own order rather than the motion spec's, because a
2091
+ // slider's reference to an animation is an ordinal in the format and the
2092
+ // editor re-sorts this object — see `editorAnimationOrder`. The sort is
2093
+ // applied HERE and not to the loop above, so what the compiler reads, the
2094
+ // order it reports durations in, and which animation a CompileError names
2095
+ // first are all still the spec's own; only the emitted key order moves.
2096
+ animations: editorAnimationOrder(animations),
1814
2097
  };
1815
2098
  if (constraints.length) skeleton.constraints = constraints;
1816
2099
 
package/src/types.ts CHANGED
@@ -835,7 +835,21 @@ export interface SpineSkeletonJson {
835
835
  }>;
836
836
  /**
837
837
  * Event definitions, keyed by name (`SkeletonJson.ts:451-464`). An object, not
838
- * an array — the one top-level collection in the format that is.
838
+ * an array — one of the **two** top-level collections in the format that are,
839
+ * `animations` being the other.
840
+ *
841
+ * ⚠️ This sentence said "the one" until issue #535, and the collection it was
842
+ * overlooking is where the defect that card is about lived. The distinction is
843
+ * not cosmetic: the binary format addresses both of these by ORDINAL
844
+ * (`SkeletonBinary`: `animations[readInt()]` for a slider's animation,
845
+ * `events[readInt()]` for an event key), and an editor round trip was measured
846
+ * to re-key every name-keyed object in codepoint order while returning every
847
+ * ARRAY in the order it was given. So a reference into either of these two is
848
+ * a reference whose ordinal an editor can move, and a reference into
849
+ * `bones` / `slots` / `skins` / `constraints` is not. `animations` is emitted
850
+ * in the editor's order for that reason (`compile.ts`'s
851
+ * `editorAnimationOrder`); whether `events` needs the same is unmeasured,
852
+ * because no editor export on hand carries more than one event.
839
853
  */
840
854
  events?: Record<string, SpineEvent>;
841
855
  animations: Record<