spine-rigc 0.18.1 → 0.19.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/AUTHORING.md CHANGED
@@ -1406,11 +1406,76 @@ sharing one of those overwrite each other whatever you write. A40 names that cas
1406
1406
  separately, because the fix is different: key such a property from one slider
1407
1407
  only, or move both edits into the single animation one slider applies.
1408
1408
 
1409
+ #### 3.5.2.1 What each `property` can actually be read AS
1410
+
1411
+ **A `property` under `local: false` is read through the world transform, and four
1412
+ of those readings are bounded.** A range that names values the reader cannot
1413
+ return is dead there: the dial moves, the reading does not follow, and nothing at
1414
+ runtime says so. [measured] against `spine-core` 4.3.13, one reader at a time —
1415
+ `bench/studies/2026-09-06-readers`:
1416
+
1417
+ | `property` | `"local"` | Reads | Producible floor | Producible ceiling |
1418
+ | --- | --- | --- | --- | --- |
1419
+ | `rotate` | `false` | `FromRotate`, world | `0`, reached | `360`, reached |
1420
+ | `rotate` | `true` | `FromRotate`, local | none | none |
1421
+ | `x` | `false` | `FromX`, world | none | none |
1422
+ | `x` | `true` | `FromX`, local | none | none |
1423
+ | `y` | `false` | `FromY`, world | none | none |
1424
+ | `y` | `true` | `FromY`, local | none | none |
1425
+ | `scaleX` | `false` | `FromScaleX`, world | `0`, reached | none |
1426
+ | `scaleX` | `true` | `FromScaleX`, local | none | none |
1427
+ | `scaleY` | `false` | `FromScaleY`, world | `0`, reached | none |
1428
+ | `scaleY` | `true` | `FromScaleY`, local | none | none |
1429
+ | `shearY` | `false` | `FromShearY`, world | `-449.99999468`, reached | `269.99999468`, reached |
1430
+ | `shearY` | `true` | `FromShearY`, local | none | none |
1431
+
1432
+ - **`none` is not "very large"** — those readers are `source.<field> + offset` and
1433
+ the field is whatever the animation wrote, so there is nothing there to bound.
1434
+ - **Every world row assumes the offsets are zero**, and a slider cannot make them
1435
+ anything else: `Slider.offsets` is a private all-zero array. The same six
1436
+ classes serve a transform constraint, which passes its own — there the scale
1437
+ floors move to the offset and the `shearY` window slides by it.
1438
+ - **`scaleX` / `scaleY` under `local: false` lose the sign.** The reader is
1439
+ `Math.sqrt(a² + c²)`, so a bone at `scaleX: −1` reads **`+1`**, not `−1`: a
1440
+ squash axis driven through negative scale gets the mirror of the dial you wrote.
1441
+ The floor `0` is *reached*, not approached — a bone whose own scale or whose
1442
+ parent's is 0 reads exactly 0 — so a range whose bottom is exactly 0 is fine and
1443
+ one that dips below it is dead.
1444
+ - **`shearY` under `local: false` wraps like `rotate` does, and worse.** It is a
1445
+ difference of two `atan2` calls, so at any one bone orientation the readable
1446
+ window is 360° wide — `(−270 − θx, 90 − θx]`, where `θx` is the bone's world
1447
+ x-axis angle. The bound in the table is the union over every orientation. ⇒ the
1448
+ seam is **not at a fixed value of the driven field**; it is wherever the bone is
1449
+ pointing. Prefer `local: true` for a shear axis.
1450
+ - **The bounds are not round numbers because `MathUtils.PI` is `3.1415927`** — the
1451
+ float32 π of the reference runtime. Every degree in spine-core passes through
1452
+ `180 / 3.1415927`, so a full turn converts as `359.99999468178214` and a bone at
1453
+ 360° reads 5.3e-6° rather than 0°. `shearY`'s two ends are `±2π · radDeg − 90`.
1454
+ - 🔸 A negative `skeleton.scaleX` / `scaleY` — how a consumer mirrors a character —
1455
+ changes **nothing**: every world reader divides the same factor back out, and
1456
+ [measured] the readings are identical to the digit at (1,1), (−1,1), (1,−1),
1457
+ (−1,−1), (2,0.5) and (−0.5,3). A `skeleton` scale of **zero** makes every world
1458
+ reader `NaN`, and `Math.max(0, NaN)` is NaN — but nothing in skeleton data sets
1459
+ that field, so it is the consumer's to avoid.
1460
+
1461
+ ⚠️ **`local: true` reads the number you authored only on a bone nothing else
1462
+ drives.** `Slider.update` calls `bone.appliedPose.validateLocalTransform` first,
1463
+ and on a bone a constraint moved that recomputes the local pose *from the world
1464
+ matrix* — `atan2Deg` for the angles and `Math.sqrt` for `scaleX`. [measured] on
1465
+ one rig, the same slider: a free bone at `rotation: −500` reads `−500` and the
1466
+ same bone inside a transform constraint's `bones` reads `−140.000006`; at
1467
+ `scaleX: −2` the free bone reads `−2` and the constrained one reads `+2`. The
1468
+ producible *set* is unbounded either way — the free case is in it — but if
1469
+ `local: true` is your repair for a world reader's floor, check that the driving
1470
+ bone is not itself constrained.
1471
+
1472
+ #### 3.5.2.2 The circle a `rotate` world dial has to stay inside
1473
+
1409
1474
  🚨 **A `rotate`-driven slider with `local: false` has to stay inside the circle
1410
1475
  `[0, 360]`.** `local: false` reads the bone's **world** rotation through
1411
1476
  `FromRotate.value`, which is a `Math.atan2` — so `(−180, 180]` — with
1412
1477
  `if (value < 0) value += 360` on the end, and the `offsets` a slider hands it are
1413
- all zero. `[0, 360)` is therefore the whole set of values that reader can ever
1478
+ all zero. `[0, 360]` is therefore the whole set of values that reader can ever
1414
1479
  return, and a range leaving it on either side is a wall:
1415
1480
 
1416
1481
  - **Below 0°.** A yaw axis authored the natural way — neutral at 0°, range
@@ -1431,12 +1496,66 @@ Both are refused at compile, each with its own arithmetic in the message and its
1431
1496
  own repair — *"move the range so it does not cross 0°"* and *"move the range so it
1432
1497
  does not run past 360°"*.
1433
1498
 
1499
+ ⭐ **The reading in that message is a modulo, not one turn** — the wrap the bone's
1500
+ matrix has already applied by the time `atan2` reads it, so a range that leaves
1501
+ the circle by *more* than 360° is folded all the way back into `[0, 360)`. The two
1502
+ examples above each sit within one turn, where a single ±360 gives the same
1503
+ answer; past that only the modulo does. [measured] through spine-core, a bone
1504
+ parked at **−500°** drives the slider to **3.600000 s**, which is exactly where a
1505
+ bone parked at **220°** drives it — so the reading is 220°, not −140°, and a
1506
+ refusal naming −140° would be naming a value that reader cannot return at all
1507
+ (issue [#431](https://github.com/firejune/rigc/issues/431)). The same on the other
1508
+ side: **900°** drives it to **0.200000 s**, the time a bone at **180°** selects.
1509
+
1510
+ 📐 **The consequence in that message is computed, not described.** Both refusals
1511
+ end on two numbers read off `[0, 360]` met with the driving values that reach the
1512
+ animation — the same two the message has already printed:
1513
+
1514
+ ```
1515
+ reachable = { to + (v − from) × scale : v ∈ [0, 360] } ∩ [0, duration]
1516
+ held = { v ∈ [0, 360] : the mapped time falls outside [0, duration] }
1517
+ ```
1518
+
1519
+ so the 300°..500° dial above is refused with *"This dial reaches only
1520
+ 0.000s..0.300s of the animation's 1s, and 83.3% of the circle — every reading
1521
+ below 300.000° — is held on the frame at 0.000s"*, and the −15°..15° one with
1522
+ *"…only 0.500s..1.000s of the animation's 1s, and 95.8% of the circle — every
1523
+ reading above 15.000° — is held on the frame at 1.000s"*. [measured] the first of
1524
+ those reproduces a 0.1° sweep of the rig through `spine-core` to **3.2e-8 s**,
1525
+ which is the reader's own `atan2` noise.
1526
+
1527
+ ⚠️ **`loop: true` gets a different sentence, because it is a different runtime.**
1528
+ `Slider.js:63-66` is `p.time = duration + (p.time % duration)` when the slider
1529
+ loops and `Math.max(0, p.time)` when it does not, so nothing is held on a looping
1530
+ slider — [measured] the same 300°..500° rig pins 5⁄6 of the circle to frame 0 at
1531
+ the default and pins *nothing* under `loop: true`. The refusal says so: *"Nothing
1532
+ is held: `"loop": true` wraps the time as `duration + (time % duration)`, so the
1533
+ 140.000° of the range past 360° selects nothing a reading inside the circle does
1534
+ not already select."* The range is still refused either way — a bone cannot be
1535
+ read at 500°, whatever happens to the time afterwards.
1536
+
1537
+ ⭐ **The degrees in that sentence are a *width*** — how much of `lowest..highest`
1538
+ lies outside `[0, 360]` — and not the reach from the boundary to the far end. The
1539
+ two are the same number for a range that *straddles* a boundary, as `300°..500°`
1540
+ does. A range lying **wholly** outside is told its own width instead: `400°..500°`
1541
+ reads *"the 100.000° of the range past 360°"*, and `-500°..-300°` *"the 200.000°
1542
+ of the range below 0°"* (issue
1543
+ [#434](https://github.com/firejune/rigc/issues/434) — both used to print the
1544
+ reach, which on the first of those was 140.000°, wider than the 100°-wide range
1545
+ it was describing).
1546
+
1434
1547
  ⭐ **A range ending exactly on 360° is legal**, and that is the whole turn: a
1435
1548
  wheel, a turntable, a head that goes all the way round, written `from: 0` with a
1436
- `scale` that puts 360° on the last frame. It misses exactly one value — its own
1437
- supremum and that value is not a dial position: a bone at 360° *is* a bone at
1438
- 0°, and [measured] it poses the skeleton to within **4e-7°** of it, an `atan2`
1439
- artefact rather than a frame. Swept at 0.1° over the circle, `from: 0,
1549
+ `scale` that puts 360° on the last frame. It misses **nothing**: [measured] the
1550
+ wrap `value += 360` on a reading a hair below zero *rounds*, and bisecting the
1551
+ runtime's own `v + 360 === 360` puts the threshold at exactly half an ulp of 360
1552
+ so every reading in `[-2.842170943040401e-14°,)` is read as exactly `360`, and
1553
+ the top of that range is reached rather than approached. Nor is 360 a separate
1554
+ dial position: a bone at 360° *is* a bone at 0°, and [measured] it poses the
1555
+ skeleton to within **4e-7°** of it, an `atan2` artefact rather than a frame. (The
1556
+ other half of that same `atan2` leaves a 5.3e-6°-wide hole at 180°, between
1557
+ `179.99999734…` and `180.00000265…` — measured, reported for completeness, and
1558
+ narrower than any dial anybody writes.) Swept at 0.1° over the circle, `from: 0,
1440
1559
  scale: 0.0025` on a 0.9 s animation lands every reading within **1.7e-8 s** of the
1441
1560
  time the mapping asks for; on `loop: true` the endpoint is not even distinct,
1442
1561
  closing on **0.900000 s** exactly. Use `loop: true` for a dial that really does go
@@ -2740,7 +2859,7 @@ dial and not reached at all through another.
2740
2859
  - ⚠️ **A key at a time no dial can select is named, not passed.** The cause is
2741
2860
  [#405](https://github.com/firejune/rigc/issues/405)'s wrap: `FromRotate.value`
2742
2861
  under `local: false` is an `atan2` ending `if (value < 0) value += 360`, so
2743
- **`[0, 360)` is the whole of its range** and a mapping needing anything outside
2862
+ **`[0, 360]` is the whole of its range** and a mapping needing anything outside
2744
2863
  it selects nothing. 🚨 A **rig spec** can no longer ask for one — the compiler
2745
2864
  refuses both ends of that circle (§3.5.2), the low one since #405 and the high
2746
2865
  one since [#417](https://github.com/firejune/rigc/issues/417) — but an
@@ -2795,9 +2914,45 @@ the line says so rather than picking one silently.**
2795
2914
  tie. ⛔ Neither case is a refusal and neither is guessed past — an ambiguous
2796
2915
  discovery is a thing to report.
2797
2916
 
2798
- None of this needs anything from you unless a `frame` line carries one of those
2799
- clauses. If one does, it is telling you the dial bone's parent transform is doing
2800
- something you may not have intended.
2917
+ **And a `build` says it too, on `A39`'s stats line**
2918
+ ([#427](https://github.com/firejune/rigc/issues/427)) because `explain` is not
2919
+ the loop you run, and until this the whole finding lived on a line only `explain`
2920
+ prints. Nothing appears on a rig where the two answers agreed, which is every
2921
+ `local: true` slider and every gallery example:
2922
+
2923
+ ```
2924
+ deformDialsTied=1 deformDialTied=dial|artifact:knob.x@7.071e-1|tied:knob.y@7.071e-1
2925
+ deformDialsDisagreed=1 deformDialDisagreed=dial|artifact:knob.x@2.321e-8|reaches:0.000000..0.003893s|probe:knob.y@1.000e+0|reaches:0.000000..1.000000s|outside:0.500000s+1.000000s
2926
+ ```
2927
+
2928
+ - `artifact:` is the field the **skeleton** names and what one unit of it moves the
2929
+ reading by; `probe:` is the field that **measurably** moves it and by how much.
2930
+ - `reaches:` is the part of that animation's own `0..duration` each of them can
2931
+ select. It is bounded by the same `±16777216` the dial figure is: a field that
2932
+ barely moves the reading needs an unsettable value to move it a whole second.
2933
+ - `outside:` is the key times this survey posed through `probe:` that **no
2934
+ settable value of the field the skeleton names reaches**. Each one is posed
2935
+ through that field and the runtime is asked where it landed, so the list is a
2936
+ measurement. ⭐ `outside:none` is a reading, not an absence — it says both
2937
+ answers select every frame that was measured, so the disagreement changed
2938
+ nothing about what `A39` looked at.
2939
+ - A **tie** never carries `probe:`, `reaches:` or `outside:`, and never counts as a
2940
+ disagreement. There is one belief there, not two.
2941
+
2942
+ ⛔ **None of it refuses a build**, and the reason is measured rather than chosen.
2943
+ The field the survey drives is the largest response the probe found, so its reach
2944
+ always *contains* the artifact's: a disagreement cannot make `A39` miss a frame the
2945
+ runtime reaches. And every frame it does pose is checked against `SliderPose.time`
2946
+ by spine-core itself, so it cannot make `A39` pose one that never happens either.
2947
+ What is left is a rig naming a property no settable value of turns far enough —
2948
+ which the line above tells you, and which no edit rigc could demand would fix,
2949
+ because the rig may be perfectly correct and driven through the other field.
2950
+
2951
+ None of this needs anything from you unless a `frame` line or one of those stats
2952
+ readings appears. If one does, it is telling you the dial bone's parent transform
2953
+ is doing something you may not have intended — and if `outside:` names times, it is
2954
+ telling you the dial cannot be turned to them through the property your rig spec
2955
+ declares.
2801
2956
 
2802
2957
  ⚠️ **What the artifact cannot say, and rigc therefore does not:** whether a
2803
2958
  slider's animation is *also* played on a track somewhere. Nothing in skeleton data
@@ -2973,8 +3128,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
2973
3128
  | `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
2974
3129
  | `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
2975
3130
  | `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
2976
- | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360)` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
2977
- | `… run from 300.000° to 500.000° … the whole part of the range past 360° is dead` | §3.5.2 — the same wall at the other end, and the same first repair: `"local": true`, or move the range so it does not run past 360°. `[0, 360)` is the whole of what that reader returns, so a bone turned to 500° is read as 140° and selects a time far from the one the range asked for. Ending *exactly* on 360° is fine — that is the full turn, and the only value it misses is a supremum no dial can be parked at separately |
3131
+ | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360]` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
3132
+ | `… run from 300.000° to 500.000° … the whole part of the range past 360° is dead` | §3.5.2 — the same wall at the other end, and the same first repair: `"local": true`, or move the range so it does not run past 360°. `[0, 360]` is the whole of what that reader returns, so a bone turned to 500° is read as 140° and selects a time far from the one the range asked for. Ending *exactly* on 360° is fine — that is the full turn, and it misses nothing: the wrap rounds, so a bone a hair below is read as exactly 360 |
2978
3133
  | `skin "S" activates bone "B", but that bone does not declare \`"skin": true\`` | §3.4.1 — the list and the flag are one switch; add the flag or drop the list |
2979
3134
  | `bone "B" declares \`"skin": true\` but no skin activates it` | §3.4.1 — the other half: list it in the skin it belongs to, or drop the flag |
2980
3135
  | `skin "S": uses the long form … and also has a key "X"` | §3.4.1 — move the slot inside `attachments` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.18.1",
3
+ "version": "0.19.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
@@ -3540,11 +3540,9 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3540
3540
  const atEnd = fromValue + (duration - toTime) / perUnit;
3541
3541
  const lowest = Math.min(atStart, atEnd);
3542
3542
  const highest = Math.max(atStart, atEnd);
3543
- // Which end of the circle the range leaves, and which way the reader
3544
- // moves it: below the wrap ADDS 360, past 360° there is nothing above
3545
- // to wrap from and the value arrives 360 LOWER instead. One record, so
3546
- // the end, its reading, the time it lands on and the two clauses that
3547
- // name the side are derived once rather than twice.
3543
+ // Which end of the circle the range leaves. One record, so the end, its
3544
+ // reading, the time it lands on and the two clauses that name the side
3545
+ // are derived once rather than twice.
3548
3546
  //
3549
3547
  // The two tests ARE each other's mirror, and that is deliberate: each
3550
3548
  // gives its own boundary — 0° and 360°, the two values an author aims at
@@ -3554,27 +3552,162 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3554
3552
  lowest < -SLIDER_WRAP_SLACK
3555
3553
  ? {
3556
3554
  end: lowest,
3557
- readAs: lowest + 360,
3558
3555
  side: 'below 0°',
3559
3556
  repair: 'move the range so it does not cross 0°',
3560
3557
  }
3561
3558
  : highest > 360 + SLIDER_WRAP_SLACK
3562
3559
  ? {
3563
3560
  end: highest,
3564
- readAs: highest - 360,
3565
3561
  side: 'past 360°',
3566
3562
  repair: 'move the range so it does not run past 360°',
3567
3563
  }
3568
3564
  : null;
3569
3565
  if (dead !== null) {
3570
- const lands = toTime + (dead.readAs - fromValue) * perUnit;
3566
+ /**
3567
+ * What `FromRotate.value` actually returns for a bone at that end: a
3568
+ * MODULO, not one subtraction (issue #431).
3569
+ *
3570
+ * 🚨 `lowest + 360` / `highest - 360` is right only while the range
3571
+ * stays within one turn of the circle, which every fixture in this
3572
+ * tree happened to be. Further out it printed a number the reader
3573
+ * cannot return, inside a refusal whose entire subject is which
3574
+ * readings the reader CAN return: `from: -500, scale: 0.005` over a
3575
+ * 1 s animation said *"the bone at -500.000° is read as -140.000° and
3576
+ * maps to time 1.800s"*, and -140° is not in `[0, 360)` at all. Both
3577
+ * figures in that sentence were wrong. Measured through spine-core,
3578
+ * a bone parked at -500° drives `Slider.appliedPose.time` to
3579
+ * 3.600000s — the same six decimals a bone parked at 220° drives it
3580
+ * to — so the reading is 220° and the time is 3.600s.
3581
+ *
3582
+ * ⭐ Derived off `dead.end` rather than inside the two branches, so
3583
+ * the wrap is written ONCE. That is what #424 collapsed the two ends
3584
+ * into one record for, and a second copy of `% 360` with a sign
3585
+ * edited is how the pair drifts apart again.
3586
+ *
3587
+ * ⚠️ The consequence clause below does NOT go through here and does
3588
+ * not need to: `reachLo`/`reachHi` intersect the driving window with
3589
+ * `[0, 360)` directly, which is the same set however many turns out
3590
+ * the window sits. Swept through spine-core at 0.1° over the whole
3591
+ * circle on both a beyond-a-turn low range (-500°..-300°) and a
3592
+ * beyond-`+720°` high one (100°..900°), the held fraction and the
3593
+ * frame the held arc pins to are the ones this clause names — 100.0%
3594
+ * at 1.000s and 27.8% at 0.000s — and the runtime's own applied time
3595
+ * tracks the closed form to 3.3e-8s.
3596
+ */
3597
+ const readAs = ((dead.end % 360) + 360) % 360;
3598
+ /**
3599
+ * The time this mapping puts a driving value at, before the runtime
3600
+ * touches it — the one arithmetic every figure below comes off.
3601
+ *
3602
+ * ⚠️ **Computed, not asserted** (issue #423). This clause used to end
3603
+ * *"— outside the animation's Ds. With `loop`: false that is
3604
+ * `Math.max(0, time)` holding the last frame; with `loop`: true it
3605
+ * wraps to some other frame"*, which states a consequence rather than
3606
+ * measuring one — and is flatly false for a range spanning a full
3607
+ * turn, where the wrapped reading lands INSIDE the animation. It also
3608
+ * printed both loop modes and left the reader to pick. A message that
3609
+ * hedges is a message that has not measured.
3610
+ */
3611
+ const timeAt = (value: number): number => toTime + (value - fromValue) * perUnit;
3612
+ const lands = timeAt(readAs);
3613
+ // `[0, 360)` is the whole of what `FromRotate.value` returns (issue
3614
+ // #417), so the readings that reach the animation at all are that
3615
+ // circle met with the driving window `lowest`..`highest` the range
3616
+ // clause above already prints. A second derivation beside these two
3617
+ // numbers is exactly how a pair drifts, which is why #417 collapsed
3618
+ // the two ends into one `dead` record in the first place.
3619
+ const reachLo = Math.max(0, lowest);
3620
+ const reachHi = Math.min(360, highest);
3621
+ const reaches = reachLo <= reachHi;
3622
+ const intoFrame = (time: number): number => Math.min(Math.max(time, 0), duration);
3623
+ const reachA = intoFrame(timeAt(reachLo));
3624
+ const reachB = intoFrame(timeAt(reachHi));
3625
+ const span = `${Math.min(reachA, reachB).toFixed(3)}s..${Math.max(reachA, reachB).toFixed(3)}s`;
3626
+ // ⭐ Exactly ONE arc of the circle is ever left over, and that is what
3627
+ // lets the message name one bound and one frame instead of a set: the
3628
+ // refusal means `[lowest, highest]` already runs off one end of
3629
+ // `[0, 360)`, so the part of the circle outside it is a single run.
3630
+ // (Both ends outside means the whole circle reaches — `dead` is 0° wide
3631
+ // — and neither end outside is not a refusal at all.)
3632
+ const dark = reaches ? 360 - (reachHi - reachLo) : 360;
3633
+ const heldAt = intoFrame(timeAt(reachLo > 0 ? 0 : 360));
3634
+ const arc = !reaches
3635
+ ? 'every reading'
3636
+ : reachLo > 0
3637
+ ? `every reading below ${reachLo.toFixed(3)}°`
3638
+ : `every reading above ${reachHi.toFixed(3)}°`;
3639
+ /**
3640
+ * How much of the range is a bone position no reader ever returns:
3641
+ * the WIDTH of `[lowest, highest]` lying outside `[0, 360]` (issue
3642
+ * #434).
3643
+ *
3644
+ * 🚨 `-lowest` / `highest - 360` is the distance from the boundary to
3645
+ * the FAR end, and that equals the dead width only while the range
3646
+ * STRADDLES the boundary. A range lying wholly outside had the gap
3647
+ * between the boundary and its NEAR end counted too: `400°..500°` —
3648
+ * inside one turn and reachable today — was told `140.000°` of it is
3649
+ * dead, wider than the 100° range itself, and `-500°..-300°` was told
3650
+ * `500.000°` against a true 200°. Clamping the near end to the
3651
+ * boundary is the whole of the fix, and it moves ONLY the ranges that
3652
+ * lie wholly outside: measured, a range straddling either boundary, a
3653
+ * range ending exactly on one, and a range hanging off both at once
3654
+ * all print what they printed before. `PS45` is the two that move and
3655
+ * `PS46` is the four that must not.
3656
+ *
3657
+ * ⭐ **Third instance of one shape in this clause.** #417 tested one
3658
+ * end of the range because its fixture only ever left the circle at
3659
+ * that end; #431 wrapped by a single subtraction because every
3660
+ * fixture sat within one turn; this measured to the far end because
3661
+ * `PS42` — the only control that reads this string — straddles 360°,
3662
+ * where the two arithmetics agree to the bit. Every time, a
3663
+ * computation right about the case its fixture happened to be and
3664
+ * silent about the case beside it, with no second fixture standing
3665
+ * anywhere else to say so.
3666
+ *
3667
+ * ⭐ Why it read as a measurement rather than as a bug: the wrong
3668
+ * figure is always a number ALREADY IN THE SENTENCE. Past 360°,
3669
+ * `highest - 360` reproduces `readAs` — `400°..500°` printed the same
3670
+ * `140.000°` twice, once as the reading and once as a width. Below 0°,
3671
+ * `-lowest` reproduces `dead.end` with its sign dropped.
3672
+ *
3673
+ * ⚠️ Each term stays BEHIND the test that says its side is the one
3674
+ * that crossed, and those guards are load-bearing rather than tidy —
3675
+ * measured, not argued. Drop `lowest < 0` and the low term on
3676
+ * `400°..500°` is `Math.min(0, 500) - 400 = -400`, a negative
3677
+ * contribution to a width: the refusal prints `-300.000°`, and
3678
+ * `300°..500°` and `100°..900°` move to `-160.000°` and `440.000°`.
3679
+ * Drop `highest > 360` and `-340°..-305°` prints `-630.000°`. Neither
3680
+ * is a width, and a width is what the sentence says it is.
3681
+ */
3682
+ const outside =
3683
+ (lowest < 0 ? Math.min(0, highest) - lowest : 0) + (highest > 360 ? highest - Math.max(360, lowest) : 0);
3684
+ // ⚠️ WHICH consequence the runtime produces is the slider's own `loop`,
3685
+ // read rather than guessed: `Slider.js:63-66` is
3686
+ // `p.time = duration + (p.time % duration)` when it is true and
3687
+ // `Math.max(0, p.time)` when it is false, so a reading held on one
3688
+ // frame under the second is replayed from elsewhere under the first and
3689
+ // nothing is dead in time at all. Swept through spine-core at 0.1° over
3690
+ // the whole circle before this was written, both ways.
3691
+ const consequence =
3692
+ spec.loop === true
3693
+ ? `Nothing is held: "loop": true wraps the time as \`duration + (time % duration)\`, so the ` +
3694
+ `${outside.toFixed(3)}° of the range ${dead.side} selects nothing a reading inside the circle does ` +
3695
+ 'not already select.'
3696
+ : !reaches
3697
+ ? `This dial reaches none of the animation's ${duration}s — the whole circle is held on the frame at ` +
3698
+ `${heldAt.toFixed(3)}s.`
3699
+ : dark === 0
3700
+ ? `This dial reaches only ${span} of the animation's ${duration}s, and no reading of the circle ` +
3701
+ 'reaches the rest of it.'
3702
+ : `This dial reaches only ${span} of the animation's ${duration}s, and ` +
3703
+ `${((dark / 360) * 100).toFixed(1)}% of the circle — ${arc} — is held on the frame at ` +
3704
+ `${heldAt.toFixed(3)}s.`;
3571
3705
  throw new CompileError(
3572
3706
  `${where}: drives off bone "${String(spec.bone)}" rotate with "local": false, and the driving values ` +
3573
3707
  `that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)}° to ${highest.toFixed(3)}°. ` +
3574
3708
  'A world rotation is read through `FromRotate.value`, which ends `if (value < 0) value += 360`, so the bone ' +
3575
- `at ${dead.end.toFixed(3)}° is read as ${dead.readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s — outside ` +
3576
- `the animation's ${duration}s. With "loop": false that is \`Math.max(0, time)\` holding the last frame; with ` +
3577
- `"loop": true it wraps to some other frame. Either way the whole part of the range ${dead.side} is dead and ` +
3709
+ `at ${dead.end.toFixed(3)}° is read as ${readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s. ` +
3710
+ `${consequence} The whole part of the range ${dead.side} is dead and ` +
3578
3711
  'nothing at runtime reports it. Add `"local": true` to read the bone\'s own rotation signed and unwrapped — ' +
3579
3712
  `that is the form a face axis wants — or ${dead.repair}.`,
3580
3713
  );
@@ -331,6 +331,94 @@ export interface DeformReach {
331
331
  label: string;
332
332
  }
333
333
 
334
+ /** The span of one animation's own `0..duration` a dial can select, in seconds. */
335
+ export interface DialSpan {
336
+ lo: number;
337
+ hi: number;
338
+ }
339
+
340
+ /**
341
+ * A dial whose probe **tied** and whose artifact broke the tie (issue #419).
342
+ *
343
+ * 🔒 **Not a disagreement, and it must never be reported as one.** There is one
344
+ * belief here, not two: the probe measured two fields moving the reading by the
345
+ * same amount, named neither, and the skeleton's own reader said which of them
346
+ * the author wrote. A `FromX` slider under `local: false` on a bone whose parent
347
+ * is at 45° is exactly that, and it is legitimate geometry. So there is no second
348
+ * answer, no second reach and nothing to compare — which is why this is a type of
349
+ * its own and not a `verdict` field on the one below.
350
+ */
351
+ export interface DeformDialTie {
352
+ /** The slider whose dial this is. */
353
+ slider: string;
354
+ /** Its driving bone. */
355
+ bone: string;
356
+ /** The field the artifact named, which is therefore the one the survey drives. */
357
+ drive: string;
358
+ /** What one step of it moved the reading by. */
359
+ driveResponse: number;
360
+ /** The other fields inside `DIAL_PROBE_MARGIN` of it, and what each moved it by. */
361
+ rivals: Array<{ field: string; response: number }>;
362
+ }
363
+
364
+ /**
365
+ * A dial the artifact and the probe name **differently**, with the span each of
366
+ * their answers can select (issues #419, #427).
367
+ *
368
+ * ⭐ Structured, and on the survey rather than only inside `DeformReach.label`,
369
+ * because that label is prose only `explain` prints. A `build`-only run is the
370
+ * loop an agent that cannot see the rig actually runs, and until this existed the
371
+ * fact that rigc's two halves disagreed about which dial was turned reached that
372
+ * run not at all.
373
+ *
374
+ * 🚨 **What the two reaches settle.** The property's name is not what makes this
375
+ * survey trustworthy — the set of frames it poses is. So both answers are turned
376
+ * into the span of the animation each can select, and `outside` is the part of
377
+ * what was posed that the artifact's answer could not have reached. Measured
378
+ * (issue #427): under a disagreement `driveReach` **always contains**
379
+ * `statedReach`, because the driven field is the largest response the probe found
380
+ * and a disagreement needs it to clear the artifact's field by
381
+ * `DIAL_PROBE_MARGIN`. So the survey never poses fewer frames than the artifact's
382
+ * answer would have, and `outside` is what the artifact's answer would have
383
+ * MISSED — never what this one invented.
384
+ */
385
+ export interface DeformDialDispute {
386
+ /** The slider whose dial this is. */
387
+ slider: string;
388
+ /** Its driving bone. */
389
+ bone: string;
390
+ /**
391
+ * The field the artifact names, rig-spec spelled — or the reader's class name
392
+ * when it is one this file does not know.
393
+ */
394
+ stated: string;
395
+ /** What one step of the artifact's field moves the reading by. */
396
+ statedResponse: number | null;
397
+ /**
398
+ * The part of the animation's own `0..duration` the artifact's field can
399
+ * select, or `null` when it can select none of it — and on the one reader
400
+ * this file cannot name, where there is no field to probe.
401
+ */
402
+ statedReach: DialSpan | null;
403
+ /** The field the survey drives, which is the one that measurably moves the reading. */
404
+ drive: string;
405
+ /** What one step of THAT moves the reading by. */
406
+ driveResponse: number;
407
+ /** The part of `0..duration` the drive can select, or `null` when it can select none. */
408
+ driveReach: DialSpan | null;
409
+ /**
410
+ * The deform key times this survey posed through `drive` that no settable value
411
+ * of the artifact's field reaches, in ascending order.
412
+ *
413
+ * ⭐ Empty is a **reading**, not an absence: it says both answers pose the same
414
+ * frames, so the disagreement changed nothing about what was measured. A39
415
+ * prints `outside:none` for it rather than omitting the field, because a
416
+ * comparison that was made and came out equal must not look like one nobody
417
+ * made.
418
+ */
419
+ outside: number[];
420
+ }
421
+
334
422
  /** The reach every animation has when no slider applies it. */
335
423
  const TRACK_REACH: DeformReach = {
336
424
  kind: 'track',
@@ -594,6 +682,17 @@ export interface DeformSurvey {
594
682
  * and this is (`DW16`).
595
683
  */
596
684
  spanProbes: number;
685
+ /**
686
+ * Dials whose probe tied and whose artifact broke the tie, in the skeleton's
687
+ * own constraint order (issue #419). Empty on every rig where it did not.
688
+ */
689
+ dialTies: DeformDialTie[];
690
+ /**
691
+ * Dials the artifact and the probe name differently, in the same order, each
692
+ * carrying both answers, both reaches and the frames the artifact's answer
693
+ * could not have posed (issues #419, #427).
694
+ */
695
+ dialDisputes: DeformDialDispute[];
597
696
  }
598
697
 
599
698
  /**
@@ -693,6 +792,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
693
792
  let notReachable = 0;
694
793
  let notReachableReversed = 0;
695
794
  let spansNotScanned = 0;
795
+ const dialTies: DeformDialTie[] = [];
796
+ const dialDisputes: DeformDialDispute[] = [];
696
797
  const reaches = reachesOf(data);
697
798
  for (const anim of data.animations) {
698
799
  // One pass per way in (issue #407). The animations nothing applies get the
@@ -701,6 +802,17 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
701
802
  const poseFrame = (time: number): PoseOfFrame =>
702
803
  dials === null ? { posed: poseAt(data, anim.name, time), dial: null } : poseDial(data, dials, time);
703
804
  const reach = dials === null ? TRACK_REACH : dials.reach;
805
+ /**
806
+ * The key times this plan actually **reached**, for the reach comparison
807
+ * below (#427).
808
+ *
809
+ * ⚠️ A key the drive itself could not select is not in here. It is already
810
+ * named on the stats line as `deformKeysUnreachable`, with the ask and the
811
+ * bound, and the artifact's answer cannot reach it either — so listing it
812
+ * as something the artifact's answer missed would count one defect twice
813
+ * and inflate a disagreement with a frame the disagreement did not cost.
814
+ */
815
+ const posedTimes = new Set<number>();
704
816
  for (const timeline of anim.timelines) {
705
817
  if (!(timeline instanceof DeformTimeline)) continue;
706
818
  timelines++;
@@ -748,6 +860,7 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
748
860
  notDrawnReversed += frameMeasure.measure.reversed.length;
749
861
  }
750
862
  keys.push({ ...named, key: frame, time, ...frameMeasure.measure });
863
+ if (at.dial?.unreachable !== true) posedTimes.add(time);
751
864
  if (previous !== null) {
752
865
  // ⚠️ A span whose end is a frame the runtime cannot reach has no
753
866
  // interpolation to scan: the anchors it would solve the quadratic
@@ -773,6 +886,56 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
773
886
  previous = frameMeasure;
774
887
  }
775
888
  }
889
+ // --- what the artifact's own answer could NOT have posed (issue #427) ---
890
+ //
891
+ // ⭐ Posed, not predicted. The survey builds a second plan out of the field
892
+ // the SKELETON names and runs it through the same `poseDial` every real
893
+ // frame goes through, so each entry is spine-core saying "no settable value
894
+ // of this field lands me on that time" rather than this file inferring it
895
+ // off an interval. Measured (#427): the list is empty whenever the two
896
+ // answers reach the same span, and empty is the reading that says the
897
+ // disagreement changed nothing about which frames were measured.
898
+ //
899
+ // 🚨 And nothing is reported at all about a dial that posed NOTHING — a
900
+ // slider whose animation carries no deform timeline. `outside` would be
901
+ // empty there for the one reason that must never print as agreement:
902
+ // there were no frames to disagree about. A comparison of nothing and a
903
+ // comparison that came out equal are the vacuous pass this file exists
904
+ // to keep apart.
905
+ if (posedTimes.size === 0) continue;
906
+ // A plan is visited exactly once — a slider names one animation — so the
907
+ // two lists need no de-duplication and come out in the skeleton's own
908
+ // constraint order.
909
+ if (dials?.tie) dialTies.push(dials.tie);
910
+ if (dials?.dispute) dialDisputes.push(dials.dispute);
911
+ if (dials?.dispute && dials.statedMap !== null) {
912
+ const shadow: DialPlan = {
913
+ ...dials,
914
+ field: dials.statedMap.field,
915
+ u0: dials.statedMap.u0,
916
+ v0: dials.statedMap.v0,
917
+ u1: dials.statedMap.u1,
918
+ v1: dials.statedMap.v1,
919
+ };
920
+ // ⚠️ The one time posing cannot answer: a field that moves the reading by
921
+ // NOTHING has no map to invert, so `poseDial` divides by zero and calls
922
+ // every time out of bounds — including the setup time, which that field
923
+ // reaches by being left alone. The reach says which one that is, and it
924
+ // is a single point. Nothing in spine-core 4.3 has been measured getting
925
+ // here (a parent at exactly 90° still moves a world x reading by 2.3e-8),
926
+ // and a report that over-stated a disagreement by one key would be the
927
+ // false red this file has paid for twice.
928
+ const flat = dials.statedMap.v1 === dials.statedMap.v0;
929
+ const only = dials.dispute.statedReach;
930
+ const outside = [...posedTimes]
931
+ .filter((time) =>
932
+ flat
933
+ ? only === null || Math.abs(time - only.lo) > DIAL_TIME_EPSILON
934
+ : poseDial(data, shadow, time).dial?.unreachable === true,
935
+ )
936
+ .sort((a, b) => a - b);
937
+ dials.dispute.outside.push(...outside);
938
+ }
776
939
  }
777
940
  }
778
941
  let spanProbes = 0;
@@ -802,6 +965,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
802
965
  spansUnconfirmed,
803
966
  spansNotScanned,
804
967
  spanProbes,
968
+ dialTies,
969
+ dialDisputes,
805
970
  };
806
971
  }
807
972
 
@@ -913,6 +1078,13 @@ interface DialDiscovery {
913
1078
  interface DialPlan {
914
1079
  slider: SliderData;
915
1080
  reach: DeformReach;
1081
+ /**
1082
+ * The probe's tie, when it had one — the survey collects these so a `build`
1083
+ * sees them and not only `explain` (issues #419, #427).
1084
+ */
1085
+ tie: DeformDialTie | null;
1086
+ /** The two answers and their two reaches, when they named different fields. */
1087
+ dispute: DeformDialDispute | null;
916
1088
  /**
917
1089
  * The bone field that drives it, or `null` on a bone-less slider — whose time
918
1090
  * IS its pose value and is set directly.
@@ -923,6 +1095,17 @@ interface DialPlan {
923
1095
  v0: number;
924
1096
  u1: number;
925
1097
  v1: number;
1098
+ /**
1099
+ * The ARTIFACT's own field as a map of its own, kept only on a disputed dial
1100
+ * (issue #427).
1101
+ *
1102
+ * ⭐ It is what lets `DeformDialDispute.outside` be **posed** rather than
1103
+ * predicted: the survey builds a second plan out of it and runs the same
1104
+ * `poseDial` the real frames go through, so "no settable value of `knob.x`
1105
+ * reaches t=0.5s" is a measurement taken against spine-core and not an
1106
+ * inference off an interval.
1107
+ */
1108
+ statedMap: DialProbe | null;
926
1109
  }
927
1110
 
928
1111
  /**
@@ -1104,22 +1287,26 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
1104
1287
  };
1105
1288
  // The bone-less form: `Slider.update` leaves `p.time` alone, so the dial IS the
1106
1289
  // pose value and the map is the identity.
1107
- if (slider.bone === null) return { slider, reach: reach(null), field: null, u0: 0, v0: 0, u1: 1, v1: 1 };
1290
+ if (slider.bone === null) {
1291
+ return { slider, reach: reach(null), tie: null, dispute: null, field: null, u0: 0, v0: 0, u1: 1, v1: 1, statedMap: null };
1292
+ }
1108
1293
  const skeleton = new Skeleton(data);
1109
1294
  const instance = sliderOn(skeleton, slider);
1110
1295
  const bone = instance?.bone ?? null;
1111
1296
  if (instance === null || bone === null) return null;
1112
- const probes: DialProbe[] = [];
1297
+ // ⚠️ Every field is kept, the dead ones included, because the ARTIFACT may name
1298
+ // one of them: a reach comparison needs the map of the field the skeleton
1299
+ // declares even when that field moves the reading by nothing at all (#427).
1300
+ const all: DialProbe[] = [];
1113
1301
  for (const field of DIAL_FIELDS) {
1114
1302
  const step = dialStep(field);
1115
1303
  skeleton.setupPose();
1116
1304
  const base = bone.pose[field];
1117
1305
  const v0 = dialValue(skeleton, slider, bone, field, base);
1118
1306
  const v1 = dialValue(skeleton, slider, bone, field, base + step);
1119
- const response = Math.abs(v1 - v0);
1120
- if (!Number.isFinite(response) || response === 0) continue;
1121
- probes.push({ field, response, u0: base, v0, u1: base + step, v1 });
1307
+ all.push({ field, response: Math.abs(v1 - v0), u0: base, v0, u1: base + step, v1 });
1122
1308
  }
1309
+ const probes = all.filter((p) => Number.isFinite(p.response) && p.response !== 0);
1123
1310
  // Nothing moves it: a bone another constraint pins, or a reader that cannot see
1124
1311
  // this bone at all. A37 owns the `scale: 0` shape of the same silence.
1125
1312
  if (probes.length === 0) return null;
@@ -1140,10 +1327,80 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
1140
1327
  driveResponse: chosen.response,
1141
1328
  rivals: leaders.filter((p) => p.field !== chosen.field).map((p) => ({ field: p.field, response: p.response })),
1142
1329
  statedResponse:
1143
- stated === null || stated === chosen.field ? null : (probes.find((p) => p.field === stated)?.response ?? 0),
1330
+ stated === null || stated === chosen.field ? null : (all.find((p) => p.field === stated)?.response ?? 0),
1144
1331
  verdict,
1145
1332
  };
1146
- return { slider, reach: reach(discovery), field: chosen.field, u0: chosen.u0, v0: chosen.v0, u1: chosen.u1, v1: chosen.v1 };
1333
+ // 🔒 A tie and a disagreement are built as two different things, because they
1334
+ // ARE two different things: a tie has one belief the artifact broke, and a
1335
+ // disagreement has two that have to be compared. Folding them into one record
1336
+ // with a `verdict` field is how a report comes to say "disagreed" about
1337
+ // legitimate geometry (issues #419, #427).
1338
+ const statedMap = stated === null ? null : (all.find((p) => p.field === stated) ?? null);
1339
+ const tie: DeformDialTie | null =
1340
+ verdict !== 'settled'
1341
+ ? null
1342
+ : {
1343
+ slider: slider.name,
1344
+ bone: boneName,
1345
+ drive: DIAL_PROPERTY[chosen.field],
1346
+ driveResponse: chosen.response,
1347
+ rivals: discovery.rivals.map((r) => ({ field: DIAL_PROPERTY[r.field], response: r.response })),
1348
+ };
1349
+ const dispute: DeformDialDispute | null =
1350
+ verdict !== 'disagreed'
1351
+ ? null
1352
+ : {
1353
+ slider: slider.name,
1354
+ bone: boneName,
1355
+ stated: stated === null ? discovery.reader : DIAL_PROPERTY[stated],
1356
+ statedResponse: discovery.statedResponse,
1357
+ statedReach: statedMap === null ? null : dialReachOf(slider, statedMap),
1358
+ drive: DIAL_PROPERTY[chosen.field],
1359
+ driveResponse: chosen.response,
1360
+ driveReach: dialReachOf(slider, chosen),
1361
+ outside: [],
1362
+ };
1363
+ return {
1364
+ slider,
1365
+ reach: reach(discovery),
1366
+ tie,
1367
+ dispute,
1368
+ field: chosen.field,
1369
+ u0: chosen.u0,
1370
+ v0: chosen.v0,
1371
+ u1: chosen.u1,
1372
+ v1: chosen.v1,
1373
+ statedMap: dispute === null ? null : statedMap,
1374
+ };
1375
+ }
1376
+
1377
+ /**
1378
+ * The part of an animation's own `0..duration` one field of the driving bone can
1379
+ * select, or `null` when it can select none of it (issue #427).
1380
+ *
1381
+ * ⭐ **Closed form, and exact against the thing that decides.** `poseDial` refuses
1382
+ * outright a drive whose magnitude exceeds `DIAL_DRIVE_LIMIT`, and the two maps
1383
+ * between a field and a time are both affine — the probe's `field -> value`, and
1384
+ * `Slider.update`'s own `value -> time` inverted. So the times a *settable* value
1385
+ * of this field asks for are an interval, and its two ends are `±DIAL_DRIVE_LIMIT`
1386
+ * put through both. A field that moves the reading by nothing gives a single
1387
+ * point, which is the honest answer for it: the setup time and no other.
1388
+ *
1389
+ * ⚠️ It is a statement about what can be **asked for**, not about what the runtime
1390
+ * then does with it — `FromRotate`'s `[0, 360)` wrap can refuse a time this
1391
+ * interval contains. That is why the frames a disputed dial could not have posed
1392
+ * are POSED rather than read off here.
1393
+ */
1394
+ function dialReachOf(slider: SliderData, map: DialProbe): DialSpan | null {
1395
+ const timeAt = (u: number): number => {
1396
+ const value = map.v0 + ((u - map.u0) * (map.v1 - map.v0)) / (map.u1 - map.u0);
1397
+ return slider.offset + (value - slider.property.offset) * slider.scale;
1398
+ };
1399
+ const ends = [timeAt(-DIAL_DRIVE_LIMIT), timeAt(DIAL_DRIVE_LIMIT)];
1400
+ if (!ends.every((t) => Number.isFinite(t))) return null;
1401
+ const lo = Math.max(0, Math.min(ends[0], ends[1]));
1402
+ const hi = Math.min(slider.animation.duration, Math.max(ends[0], ends[1]));
1403
+ return lo <= hi ? { lo, hi } : null;
1147
1404
  }
1148
1405
 
1149
1406
  /**
package/src/validate.ts CHANGED
@@ -45,7 +45,14 @@ import {
45
45
  // A19 needs the DECODED page, not its header, to measure one region's own
46
46
  // rectangle on a shared page.
47
47
  import { readPlate } from '../tools/plate.ts';
48
- import { surveyDeformKeys, unreachableWhy, type DeformReach } from './deformmeasure.ts';
48
+ import {
49
+ surveyDeformKeys,
50
+ unreachableWhy,
51
+ type DeformDialDispute,
52
+ type DeformDialTie,
53
+ type DeformReach,
54
+ type DialSpan,
55
+ } from './deformmeasure.ts';
49
56
  import { colourTypeName, readPngInfo } from './png.ts';
50
57
  import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
51
58
  import type { RigInfo } from './types.ts';
@@ -238,6 +245,49 @@ function frameClause(reach: DeformReach): string {
238
245
  return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
239
246
  }
240
247
 
248
+ /**
249
+ * A dial's reach as A39's stats line spells it, or `none` when it can select no
250
+ * part of its animation at all.
251
+ *
252
+ * Six decimals because a key time has six: a reach whose end is printed coarser
253
+ * than the times it is compared against cannot be read against them.
254
+ */
255
+ function dialSpanText(span: DialSpan | null): string {
256
+ return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
257
+ }
258
+
259
+ /**
260
+ * One disputed dial on A39's stats line — both answers, both reaches, and the
261
+ * frames the artifact's answer could not have posed (issue #427).
262
+ *
263
+ * ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
264
+ * what decides whether a disagreement changed anything the survey measured, and a
265
+ * comparison that came out equal must not look like one nobody made. It is the
266
+ * difference between "both answers pose the same frames, so the disagreement is a
267
+ * fact about rigc and not about this rig" and "these key times were surveyed
268
+ * through a field the skeleton does not name and no settable value of the one it
269
+ * does reaches them".
270
+ *
271
+ * ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
272
+ * a value with a space in it turns one reading into two.
273
+ */
274
+ function dialDisputeText(dispute: DeformDialDispute): string {
275
+ const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
276
+ return (
277
+ `${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
278
+ `|reaches:${dialSpanText(dispute.statedReach)}` +
279
+ `|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
280
+ `|reaches:${dialSpanText(dispute.driveReach)}` +
281
+ `|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
282
+ );
283
+ }
284
+
285
+ /** One tied dial on the same line, in the shape that cannot be read as a dispute. */
286
+ function dialTieText(tie: DeformDialTie): string {
287
+ const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
288
+ return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
289
+ }
290
+
241
291
  const FRAME = 1 / 60;
242
292
  const STEP_FRAMES = 120;
243
293
 
@@ -1668,6 +1718,36 @@ export function validate(input: ValidateInput): ValidateReport {
1668
1718
  );
1669
1719
  }
1670
1720
  const survey = surveyDeformKeys(data, new Set(input.rig.deformMayFold));
1721
+ // 🚨 Which dial was turned, when rigc's two halves did not simply agree
1722
+ // about that — and BEFORE any of the returns below, because every one of
1723
+ // them is a run that posed frames through this dial (issue #427).
1724
+ //
1725
+ // The verdict used to live in `DeformReach.label`, which `explain` prints
1726
+ // and nothing else does, so a `build`-only run — the normal loop, and the
1727
+ // one an agent that cannot see the rig actually runs — never learned that
1728
+ // the artifact and the probe named different fields.
1729
+ //
1730
+ // ⛔ Not a refusal, and the measurement rather than taste is why (#427).
1731
+ // The frames this survey posed were each checked against `SliderPose.time`
1732
+ // by the runtime itself, so a disagreement cannot make it pose one that
1733
+ // does not happen; and the field it drives is the largest response the
1734
+ // probe found, so it cannot make it miss one that does. What a
1735
+ // disagreement CAN do is leave the rig naming a property no settable value
1736
+ // of turns far enough — which is what `outside` measures and what an
1737
+ // author can act on. A refusal would refuse a rig spine-core poses
1738
+ // correctly at every key, with no edit that would make it green.
1739
+ //
1740
+ // 🔒 A tie is not a disagreement and gets a line that cannot be read as
1741
+ // one: there the probe named no field, the artifact broke the tie, and the
1742
+ // parent-45° geometry that reaches it is legitimate.
1743
+ if (survey.dialTies.length) {
1744
+ stats.deformDialsTied = survey.dialTies.length;
1745
+ stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
1746
+ }
1747
+ if (survey.dialDisputes.length) {
1748
+ stats.deformDialsDisagreed = survey.dialDisputes.length;
1749
+ stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
1750
+ }
1671
1751
  /** A key this rule is refusing, by the triple that identifies it. */
1672
1752
  const refusedKey = new Set<string>();
1673
1753
  for (const key of survey.keys) {