spine-rigc 0.30.0 → 0.32.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/src/validate.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  BoundingBoxAttachment,
24
24
  ClippingAttachment,
25
25
  type ConstraintTimeline,
26
+ type CurveTimeline,
26
27
  DeformTimeline,
28
+ Inherit,
27
29
  isBoneTimeline,
28
30
  isConstraintTimeline,
29
31
  isSlotTimeline,
@@ -31,6 +33,7 @@ import {
31
33
  MixFrom,
32
34
  PathAttachment,
33
35
  PathConstraintData,
36
+ PathConstraintMixTimeline,
34
37
  Physics,
35
38
  PhysicsConstraintData,
36
39
  PhysicsConstraintPose,
@@ -41,6 +44,7 @@ import {
41
44
  Skeleton,
42
45
  SkeletonJson,
43
46
  SliderData,
47
+ SliderMixTimeline,
44
48
  TextureAtlas,
45
49
  type TextureAtlasRegion,
46
50
  type Timeline,
@@ -50,8 +54,10 @@ import {
50
54
  // A19 needs the DECODED page, not its header, to measure one region's own
51
55
  // rectangle on a shared page.
52
56
  import { readPlate } from '../tools/plate.ts';
53
- import { pageFootprint } from './atlas.ts';
57
+ import { pageFootprint, pageGridSentence } from './atlas.ts';
54
58
  import {
59
+ BEZIER_POINTS,
60
+ curveStorage,
55
61
  surveyDeformKeys,
56
62
  unreachableWhy,
57
63
  type DeformDialDispute,
@@ -66,12 +72,16 @@ import {
66
72
  type SpineGeneration,
67
73
  } from './generation.ts';
68
74
  import { colourTypeName, readPngHeader } from './png.ts';
75
+ import { BONE_INHERIT_KNOWN } from './rig.ts';
69
76
  import {
70
77
  CHANNELS_BY_KIND,
71
78
  KEY_TIME_EPSILON,
72
79
  PHYSICS_POSE_RULES,
73
80
  physicsKeyRefusal,
81
+ physicsOutsideSays,
74
82
  physicsRuleFor,
83
+ type PhysicsPoseRule,
84
+ SEQUENCE_MODES,
75
85
  SLOT_COLOR_CHANNELS,
76
86
  walkTimelines,
77
87
  } from './timelines.ts';
@@ -197,6 +207,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
197
207
  A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
198
208
  A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
199
209
  A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
210
+ A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
200
211
  };
201
212
 
202
213
  /**
@@ -303,6 +314,8 @@ export const SKIP_NO_TWO_COLOR_TINT =
303
314
  export const SKIP_NO_SEPARABLE_COLOR =
304
315
  'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
305
316
  export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
317
+ export const SKIP_NO_SEQUENCE =
318
+ 'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
306
319
  /**
307
320
  * A09's, which predates this list and joins it rather than being rewritten: it
308
321
  * is the same fact about the same subject, and a control that compares against
@@ -458,6 +471,55 @@ const NAMED_TIMELINE_GROUPS = ['path', 'physics', 'slider'] as const;
458
471
  const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
459
472
  const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]> = new Set(['x', 'y'] as const);
460
473
 
474
+ /**
475
+ * The half of a muted-at-rest refusal that says what was searched, and how
476
+ * widely — one text for `A23`, `A36` and `A37`, which ask one question of three
477
+ * constraint kinds (issues #743, #752). A rig with no animation at all says
478
+ * "none of the 0 animations" rather than implying somebody keyed something.
479
+ */
480
+ function noneKeysItsMixAbove0(animations: number): string {
481
+ return `none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0`;
482
+ }
483
+
484
+ /** The two repairs a muted-at-rest refusal names, since either one is a rig the runtime plays. */
485
+ const REST_OR_KEY_ITS_MIX = 'rest it above 0, or key its mix above 0 in an animation';
486
+
487
+ /**
488
+ * Every value one channel of a curve timeline can pose while it plays: each
489
+ * key's own value, and every sample of each Bezier segment the parser built
490
+ * between two keys (issue #752).
491
+ *
492
+ * ⚠️ The samples are the half a reading of the keys alone misses. The runtime
493
+ * interpolates a Bezier segment between the points `setBezier` stored
494
+ * (`Animation.js:257-298`), not between the two keys, so two keys of 0 joined
495
+ * by a curve whose handles lie above 0 pose values above 0 in between.
496
+ * [measured] on generated fixtures, a `mix` pair of 0 → 0 with its handles at
497
+ * 0.8 poses a path constraint's bone up to 32.69 away from the flat pair, a
498
+ * slider's by 0.54 and a physics constraint's by 3.81. Between two stored
499
+ * points the runtime is linear, so nothing it poses lies outside this list's
500
+ * range — which is what makes "is any of these above 0" the whole question.
501
+ *
502
+ * Channel `c` of frame `f` is `frames[f * entries + 1 + c]`. `curves[f]` is 0
503
+ * for linear, 1 for stepped and `2 + i` for a Bezier whose points start at `i`
504
+ * (`Animation.js:259-261`); `readCurve` stores one segment per channel in
505
+ * order, so channel `c`'s points start `2 * BEZIER_POINTS * c` further on, as
506
+ * `(time, value)` pairs. The storage is read through `curveStorage`, the one
507
+ * reach into that protected array, which `deformmeasure.ts` owns.
508
+ */
509
+ function curveChannelValues(timeline: CurveTimeline, channel: number): number[] {
510
+ const entries = timeline.getFrameEntries();
511
+ const curves = curveStorage(timeline);
512
+ const values: number[] = [];
513
+ for (let i = 0, frame = 0; i < timeline.frames.length; i += entries, frame++) {
514
+ values.push(timeline.frames[i + 1 + channel]);
515
+ const code = curves[frame];
516
+ if (!(code >= 2)) continue;
517
+ const start = code - 2 + 2 * BEZIER_POINTS * channel;
518
+ for (let point = 0; point < BEZIER_POINTS; point++) values.push(curves[start + 2 * point + 1]);
519
+ }
520
+ return values;
521
+ }
522
+
461
523
  /**
462
524
  * What A23 says about a SETUP pose outside its bound, per property.
463
525
  *
@@ -481,13 +543,21 @@ const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]
481
543
  * other half was looked for and how wide the search was. A rig with no animation
482
544
  * at all then says "none of the 0 animations" rather than implying somebody
483
545
  * keyed something.
546
+ *
547
+ * `rule` is the row the value was judged by, and only the `strength` sentence
548
+ * reads it: its two arms live on the row (`outside`), beside the key's own
549
+ * sentence, so what the setup pose and a key say about one number is one text.
484
550
  */
485
- const SETUP_POSE_SAYS: Record<string, (pose: PhysicsConstraintPose, animations: number) => string> = {
486
- mix: (pose, animations) =>
487
- `has mix ${pose.mix} and none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0; ` +
488
- 'it is muted — rest it above 0, or key its mix above 0 in an animation',
551
+ const SETUP_POSE_SAYS: Record<
552
+ string,
553
+ (pose: PhysicsConstraintPose, animations: number, rule: PhysicsPoseRule) => string
554
+ > = {
555
+ mix: (pose, animations) => `has mix ${pose.mix} and ${noneKeysItsMixAbove0(animations)}; it is muted — ${REST_OR_KEY_ITS_MIX}`,
489
556
  mass: (pose) => `has massInverse ${pose.massInverse} (mass must be > 0)`,
490
- strength: (pose) => `has strength ${pose.strength}; nothing pulls it back`,
557
+ // Two arms, read off the row (issue #748): 0 is a constraint nothing pulls
558
+ // back and below 0 one that is pushed away, and the row's `why` — the key's
559
+ // sentence — quotes the second from the same object.
560
+ strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${physicsOutsideSays(rule, pose.strength)}`,
491
561
  damping: (pose) => `has damping ${pose.damping}; outside (0,1) it never settles`,
492
562
  };
493
563
 
@@ -513,6 +583,53 @@ export const PHYSICS_TIMELINE_NAMES: Record<number, string> = {
513
583
  [Property.physicsConstraintMix]: 'mix',
514
584
  };
515
585
 
586
+ /**
587
+ * Which physics constraints a physics timeline that names NO constraint writes
588
+ * into — the one reading of the unnamed form, for every rule that has to answer
589
+ * it (`A23`, `A34`, `A42`; issue #726).
590
+ *
591
+ * `SkeletonJson` gives a physics group keyed by the empty name `constraintIndex
592
+ * -1` (`SkeletonJson.js:1048-1054`), and the runtime reads that two ways. A
593
+ * value timeline writes every active physics constraint whose own data declares
594
+ * that property global — `PhysicsConstraintTimeline.apply` asks each subclass's
595
+ * `global` (`Animation.js:2067-2075`). `reset` resets every active one and asks
596
+ * no flag at all (`:2234-2238`). Activity is a skin question and is not asked
597
+ * here: a constraint a skin switches on is still one the timeline can reach.
598
+ *
599
+ * 🔑 `constraints` may be the FILE's objects rather than loaded data, and that
600
+ * is what makes this one reading rather than two: `SkeletonJson` copies each
601
+ * `…Global` field onto the data verbatim (`:313-319`), so the runtime's own
602
+ * `global`, asked of the raw object, reads exactly what it would read of the
603
+ * loaded one — a string `"false"` included, which is truthy to both. A rule that
604
+ * spelled `<property>Global` itself would be a second copy of the runtime's
605
+ * table, free to disagree with it.
606
+ */
607
+ export function unnamedPhysicsReach<C extends object>(timeline: Timeline, constraints: readonly C[]): C[] {
608
+ if (timeline instanceof PhysicsConstraintResetTimeline) return [...constraints];
609
+ if (!(timeline instanceof PhysicsConstraintTimeline)) return [];
610
+ return constraints.filter((one) => Boolean(timeline.global(one as unknown as PhysicsConstraintData)));
611
+ }
612
+
613
+ /**
614
+ * The timeline the runtime builds for one physics timeline NAME, read by the
615
+ * runtime's own parser off a skeleton that holds nothing else — or null for a
616
+ * name its physics branch skips (`SkeletonJson.js:1094`).
617
+ *
618
+ * ⚠️ This is how a raw-JSON rule gets the runtime's class for a name without a
619
+ * table of the eight: a hand-kept map from `"strength"` to
620
+ * `PhysicsConstraintStrengthTimeline` is the copy this avoids, and the parser's
621
+ * `switch` is already that map. The probe skeleton declares no constraint, so
622
+ * the only group it can carry is the unnamed one, and one key is all
623
+ * `readTimeline1` needs to build the object.
624
+ */
625
+ export function unnamedPhysicsTimeline(name: string): Timeline | null {
626
+ const probe = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(''))).readSkeletonData({
627
+ skeleton: {},
628
+ animations: { probe: { physics: { '': { [name]: [{}] } } } },
629
+ });
630
+ return probe.animations[0]?.timelines[0] ?? null;
631
+ }
632
+
516
633
  /**
517
634
  * One step of the **float32** grid at `t`, which is the grid a loaded key time
518
635
  * actually sits on: `spine-core` reads every timeline's frames into a
@@ -960,127 +1077,6 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
960
1077
  return wrote ? 'overwrites' : 'inert';
961
1078
  }
962
1079
 
963
- /**
964
- * The one page rectangle every region on a page shares: the size the atlas
965
- * declares for it against the size of the file it names.
966
- *
967
- * ⭐ **What a size that disagrees with the file is and is not**, measured rather
968
- * than assumed (issue #715). `TextureAtlas` computes every region's UVs as a
969
- * fraction of the DECLARED size — `region.u = region.x / page.width`, spine-core
970
- * 4.3.13 `dist/TextureAtlas.js:162-171` — `MeshAttachment.computeUVs` takes its
971
- * `textureWidth` from the same field (`dist/attachments/MeshAttachment.js:125`),
972
- * `RegionAttachment.computeUVs` reads nothing but `u/v/u2/v2`
973
- * (`dist/attachments/RegionAttachment.js:152-167`), and `TextureAtlasPage.setTexture`
974
- * never writes `width`/`height`. So **nothing in the region mapping reads the
975
- * texture's own size**, and a page whose PNG is the declared page RESCALED is
976
- * addressed at the same fraction of the picture whatever size the file is:
977
- * measured on a coordinate-ramp page where every texel names its own position,
978
- * rigc's own rasteriser drew 116,480 pixels in both and 0 in exactly one at a
979
- * uniform 0.5.
980
- *
981
- * ⇒ That is why this is a clause about **texel** readers rather than about
982
- * drawing, and why it is nevertheless not renderer policy. Three readers address
983
- * the page at the coordinates the atlas states, and two of them are rigc's own:
984
- * `A19`'s alpha scan below, the region lift `partPlate` traces a mesh generator
985
- * over in [`src/compile.ts`](compile.ts), and `spine-html`'s region tier, which
986
- * cuts each part with `drawImage(image, x, y, w, h, …)` and says in its own
987
- * comment that it tests against the image rather than the `size:` line. Measured
988
- * on the same page: the lift returned 768 of 768 texels from somewhere else.
989
- *
990
- * 🔑 And the format already states coarser texels honestly — `scale:`, which
991
- * rigc reads (`AtlasPage.scale`) and `--atlas-in` divides by. The same art
992
- * declared that way builds green and renders identically, so this refusal names
993
- * a repair the format provides rather than one rigc invented.
994
- */
995
- interface PageGridReading {
996
- /** file width / declared width, and the same for height. Both 1 when they agree. */
997
- readonly x: number;
998
- readonly y: number;
999
- /** One ratio for both axes — the only relation a `scale:` line can state. */
1000
- readonly uniform: boolean;
1001
- }
1002
-
1003
- /** `null` when the page declares no positive size for the ratios to divide by. */
1004
- function pageGridReading(
1005
- declared: { width: number; height: number },
1006
- file: { width: number; height: number },
1007
- ): PageGridReading | null {
1008
- if (declared.width <= 0 || declared.height <= 0) return null;
1009
- return {
1010
- x: file.width / declared.width,
1011
- y: file.height / declared.height,
1012
- // Cross-multiplied rather than compared as two divisions: the question is
1013
- // whether one rational number describes both axes, and two floats that
1014
- // round to the same digits are not that.
1015
- uniform: file.width * declared.height === file.height * declared.width,
1016
- };
1017
- }
1018
-
1019
- /** A ratio, printed the one way every message here prints one. */
1020
- function gridRatio(n: number): string {
1021
- return n.toFixed(4);
1022
- }
1023
-
1024
- /**
1025
- * The first number on this page that a `scale:` re-declaration could not carry,
1026
- * or `null` when every one of them lands on a whole texel of the file.
1027
- *
1028
- * Every number in a region block is in the page's own texel units — `bounds` and
1029
- * `offsets` alike — so re-declaring the page at the file's size means scaling all
1030
- * eight by the same ratio, and a region that then lands between texels is one no
1031
- * reader could cut out. Stated as integer arithmetic (`n * file % declared`)
1032
- * rather than as a float test, so the answer does not depend on how the ratio
1033
- * rounded. ⚠️ One ratio for both axes, so this is the UNIFORM case's question
1034
- * and is only ever asked there — a page whose two axes differ has no `scale:`
1035
- * line to be re-declared with at all.
1036
- */
1037
- function firstNumberOffTheCoarseGrid(
1038
- regions: ReadonlyArray<{
1039
- name: string;
1040
- x: number;
1041
- y: number;
1042
- width: number;
1043
- height: number;
1044
- offsetX: number;
1045
- offsetY: number;
1046
- originalWidth: number;
1047
- originalHeight: number;
1048
- }>,
1049
- declared: number,
1050
- file: number,
1051
- ): string | null {
1052
- for (const region of regions) {
1053
- const numbers: Array<[string, number]> = [
1054
- ['bounds x', region.x],
1055
- ['bounds y', region.y],
1056
- ['bounds width', region.width],
1057
- ['bounds height', region.height],
1058
- ['offsets offsetX', region.offsetX],
1059
- ['offsets offsetY', region.offsetY],
1060
- ['offsets originalWidth', region.originalWidth],
1061
- ['offsets originalHeight', region.originalHeight],
1062
- ];
1063
- for (const [field, value] of numbers) {
1064
- if ((value * file) % declared === 0) continue;
1065
- return (
1066
- `region ${JSON.stringify(region.name.trim())}'s \`${field}\` of ${value} becomes ` +
1067
- `${((value * file) / declared).toFixed(4)} on the file's own grid, which is not a whole texel`
1068
- );
1069
- }
1070
- }
1071
- return null;
1072
- }
1073
-
1074
- /** What every size-mismatch sentence says before it says what to do about it. */
1075
- const PAGE_GRID_PREAMBLE =
1076
- 'A runtime does not read the file\'s own size anywhere in the region mapping — `TextureAtlas` computes every ' +
1077
- "region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core " +
1078
- '`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ' +
1079
- "the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a " +
1080
- 'declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ' +
1081
- "validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that " +
1082
- 'cuts each part out of the page by source rectangle.';
1083
-
1084
1080
  export function validate(input: ValidateInput): ValidateReport {
1085
1081
  const failures: Failure[] = [];
1086
1082
  const passed: string[] = [];
@@ -1518,6 +1514,10 @@ export function validate(input: ValidateInput): ValidateReport {
1518
1514
  kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), String(entry.type)]);
1519
1515
  }
1520
1516
  }
1517
+ /** The file's physics constraints, which the unnamed physics group is read against. */
1518
+ const rawPhysics = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter(
1519
+ (entry): entry is Json => isObj(entry) && entry.type === 'physics',
1520
+ );
1521
1521
  let sawATimeline = false;
1522
1522
  /** One target of one group: the name resolves, the type matches, keys exist. */
1523
1523
  const checkTarget = (at: string, group: string, name: string, keyArrays: Array<[string, unknown]>): void => {
@@ -1539,6 +1539,10 @@ export function validate(input: ValidateInput): ValidateReport {
1539
1539
  );
1540
1540
  return;
1541
1541
  }
1542
+ checkKeys(at, keyArrays);
1543
+ };
1544
+ /** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
1545
+ const checkKeys = (at: string, keyArrays: Array<[string, unknown]>): void => {
1542
1546
  if (keyArrays.length === 0) {
1543
1547
  fail(
1544
1548
  'A34_CONSTRAINT_TIMELINE_TARGETS',
@@ -1580,6 +1584,36 @@ export function validate(input: ValidateInput): ValidateReport {
1580
1584
  );
1581
1585
  continue;
1582
1586
  }
1587
+ // The empty name is not a miss: it is the physics group's global form,
1588
+ // which `SkeletonJson` loads as `constraintIndex -1` rather than looking
1589
+ // anything up (`:1048-1054`), and which writes every physics constraint
1590
+ // that declares the timeline's property global (issue #726). Refusing
1591
+ // it as "no constraint called ''" was a sentence that is false about
1592
+ // the file. What CAN be wrong with it is that it reaches nobody: the
1593
+ // parser accepts it, the runtime walks every constraint, and none of
1594
+ // them takes the key.
1595
+ if (group === 'physics' && name === '') {
1596
+ sawATimeline = true;
1597
+ for (const [timelineName] of Object.entries(timelines)) {
1598
+ const timeline = unnamedPhysicsTimeline(timelineName);
1599
+ // A name the parser skips is skipped here too: no timeline exists
1600
+ // to reach anybody, and whether the NAME is right is not a target
1601
+ // question.
1602
+ if (timeline === null || unnamedPhysicsReach(timeline, rawPhysics).length > 0) continue;
1603
+ const resets = timeline instanceof PhysicsConstraintResetTimeline;
1604
+ fail(
1605
+ 'A34_CONSTRAINT_TIMELINE_TARGETS',
1606
+ `${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
1607
+ `constraint ${resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
1608
+ (rawPhysics.length === 0
1609
+ ? 'the skeleton has no physics constraint'
1610
+ : `none of ${rawPhysics.map((one) => `"${String(one.name)}"`).join(', ')} does`) +
1611
+ ' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
1612
+ );
1613
+ }
1614
+ checkKeys(at, Object.entries(timelines));
1615
+ continue;
1616
+ }
1583
1617
  checkTarget(at, group, name, Object.entries(timelines));
1584
1618
  }
1585
1619
  }
@@ -2981,6 +3015,58 @@ export function validate(input: ValidateInput): ValidateReport {
2981
3015
  if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
2982
3016
  });
2983
3017
 
3018
+ /**
3019
+ * The constraints some animation keys to a value `live` accepts, on any
3020
+ * channel of a timeline `owns` claims — the one answer to "does anything
3021
+ * switch this on" for `A23`, `A36`, `A37` and `A40` (issues #743, #752).
3022
+ *
3023
+ * ⭐ It replaced `keyedBy`, which read the raw JSON and took a non-empty key
3024
+ * array as the answer, and it did so rather than teaching that one to read
3025
+ * values, because the raw file is the wrong place to read a value: a path
3026
+ * `mix` key that omits `mixRotate` means 1 (`SkeletonJson.js:1011-1013`), a
3027
+ * `mixY` it omits means that key's `mixX`, and a Bezier between two keys
3028
+ * poses values neither key states. A raw reader would restate the parser's
3029
+ * defaults and re-derive its curves — a second opinion on the runtime's own
3030
+ * numbers — so this reads the loaded timelines instead, with
3031
+ * `curveChannelValues` for the curves. [measured] the reading `keyedBy` gave
3032
+ * was wrong in the accepting direction: a path constraint and a slider,
3033
+ * both muted at rest and keyed to 0 only, pose every bone exactly where the
3034
+ * same rig with no timeline does (max |Δ| 0.000000 over 60 steps at 60 fps) and both
3035
+ * passed.
3036
+ *
3037
+ * A timeline naming no constraint is the physics family's global form and
3038
+ * `unnamedPhysicsReach` answers who it reaches; every other constraint
3039
+ * timeline names its one constraint by index.
3040
+ */
3041
+ const keyedLive = <T extends CurveTimeline & ConstraintTimeline>(
3042
+ owns: (timeline: Timeline) => timeline is T,
3043
+ live: (timeline: T, value: number) => boolean,
3044
+ ): Set<object> => {
3045
+ const reached = new Set<object>();
3046
+ for (const animation of data.animations) {
3047
+ for (const timeline of animation.timelines) {
3048
+ if (!owns(timeline)) continue;
3049
+ let keysLive = false;
3050
+ for (let channel = 0; channel < timeline.getFrameEntries() - 1 && !keysLive; channel++) {
3051
+ keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value));
3052
+ }
3053
+ if (!keysLive) continue;
3054
+ const reach =
3055
+ timeline.constraintIndex === -1
3056
+ ? unnamedPhysicsReach(timeline, data.constraints.filter((one) => one instanceof PhysicsConstraintData))
3057
+ : [data.constraints[timeline.constraintIndex]];
3058
+ for (const one of reach) if (one) reached.add(one);
3059
+ }
3060
+ }
3061
+ return reached;
3062
+ };
3063
+
3064
+ /**
3065
+ * The one predicate a path or slider mix is judged by, at setup and on every
3066
+ * value a key poses: above 0, where `update()` does anything at all.
3067
+ */
3068
+ const mixLive = (value: number): boolean => value > 0;
3069
+
2984
3070
  // --- A23: a physics constraint that does nothing, quietly ---------------
2985
3071
  //
2986
3072
  // Every failure mode here is silent. The five component fields default to
@@ -3036,37 +3122,30 @@ export function validate(input: ValidateInput): ValidateReport {
3036
3122
  // [measured] at rest it reads NaN on every frame although an animation
3037
3123
  // keys `mass` to 1. A key cannot rescue a value that has already broken
3038
3124
  // the rig it is resting in.
3125
+ //
3126
+ // The keys are read by `keyedLive`, the one reading A36 and A37 share
3127
+ // (issue #752), through the runtime's own accessor: the pose field is
3128
+ // where the integrator reads the number, and for `mass` that is not the
3129
+ // number the key states. It counts the unnamed global form through
3130
+ // `unnamedPhysicsReach` and a Bezier segment's samples as well as its keys.
3039
3131
  const unmuted = new Map<PhysicsConstraintData, Set<string>>();
3040
3132
  const raised = new PhysicsConstraintPose();
3041
- for (const animation of data.animations) {
3042
- for (const timeline of animation.timelines) {
3043
- if (!(timeline instanceof PhysicsConstraintTimeline)) continue;
3044
- const rule = physicsRuleFor(PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] ?? '');
3045
- if (rule === undefined || !rule.inertAtSetup) continue;
3046
- const entries = timeline.getFrameEntries();
3047
- let keysInside = false;
3048
- for (let i = 0; i < timeline.frames.length && !keysInside; i += entries) {
3049
- // The runtime's own accessor, for the reason the second arm uses it:
3050
- // the pose field is where the integrator reads the number, and for
3051
- // `mass` that is not the number the key states.
3052
- timeline.set(raised, timeline.frames[i + 1]);
3053
- keysInside = rule.poseOk(raised[rule.field]);
3054
- }
3055
- if (!keysInside) continue;
3056
- // -1 is the global form: the timeline names no constraint and the
3057
- // runtime applies it to every physics constraint whose own data
3058
- // declares that property global (`Animation.js:2067-2075`). No rig
3059
- // spec can reach it — the compiler refuses a track naming a constraint
3060
- // the skeleton has not got, the empty name among them — but a file
3061
- // rigc did not write can carry one, and `timeline.global` is the
3062
- // runtime's own answer to which constraints it reaches.
3063
- for (const one of data.constraints) {
3064
- if (!(one instanceof PhysicsConstraintData)) continue;
3065
- if (timeline.constraintIndex === -1 ? !timeline.global(one) : data.constraints[timeline.constraintIndex] !== one) continue;
3066
- const by = unmuted.get(one) ?? new Set<string>();
3067
- by.add(rule.timeline);
3068
- unmuted.set(one, by);
3069
- }
3133
+ for (const rule of PHYSICS_POSE_RULES) {
3134
+ if (!rule.inertAtSetup) continue;
3135
+ const reached = keyedLive(
3136
+ (timeline): timeline is PhysicsConstraintTimeline =>
3137
+ timeline instanceof PhysicsConstraintTimeline &&
3138
+ PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
3139
+ (timeline, value) => {
3140
+ timeline.set(raised, value);
3141
+ return rule.poseOk(raised[rule.field]);
3142
+ },
3143
+ );
3144
+ for (const one of data.constraints) {
3145
+ if (!(one instanceof PhysicsConstraintData) || !reached.has(one)) continue;
3146
+ const by = unmuted.get(one) ?? new Set<string>();
3147
+ by.add(rule.timeline);
3148
+ unmuted.set(one, by);
3070
3149
  }
3071
3150
  }
3072
3151
  let mutedUntilKeyed = 0;
@@ -3092,7 +3171,7 @@ export function validate(input: ValidateInput): ValidateReport {
3092
3171
  const drivesAMesh = rule.timeline === 'damping' && meshBoneNames.has(constraint.bone.name);
3093
3172
  fail(
3094
3173
  'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
3095
- `${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length)}` +
3174
+ `${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length, rule)}` +
3096
3175
  (drivesAMesh ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
3097
3176
  );
3098
3177
  }
@@ -3246,7 +3325,9 @@ export function validate(input: ValidateInput): ValidateReport {
3246
3325
  });
3247
3326
 
3248
3327
  /**
3249
- * Does any animation key `<group>.<constraint>.<timeline>`?
3328
+ * Which path constraints and sliders an animation switches ON — `keyedLive`
3329
+ * over their `mix` timelines, judged by `mixLive`, the predicate their setup
3330
+ * pose is judged by below.
3250
3331
  *
3251
3332
  * ⭐ The reason the two assertions below need this: a constraint whose mixes
3252
3333
  * are all 0 at setup is **the idiom**, not a defect — spineboy's aim rig is
@@ -3254,30 +3335,23 @@ export function validate(input: ValidateInput): ValidateReport {
3254
3335
  * "muted" is only a finding when nothing turns it on, and that question
3255
3336
  * lives in the animations rather than in the constraint.
3256
3337
  *
3257
- * ⚠️ This comment used to add "and the reason they are not A23 with a
3258
- * different type name", which said the idiom stops at path and slider
3259
- * constraints. It does not: a production rig rests 87 of its 87 physics
3260
- * constraints at `mix` 0 and keys them up in four of its six animations
3261
- * (issue #743), so A23 asks the same question now — and does NOT ask it
3262
- * here. Two reasons, both measured: this reads the raw JSON and takes a
3263
- * non-empty key array as a rescue, so a `mix` timeline that keys **0 only**
3264
- * counts, and [measured] such a rig poses its bone exactly where one with no
3265
- * timeline at all does; and it cannot see the unnamed global form, which the
3266
- * runtime applies to every constraint declaring that property global. A23's
3267
- * arm reads the key VALUES through the runtime's own accessor and asks
3268
- * `PhysicsConstraintTimeline.global` who they reach.
3338
+ * 🔑 "Turns it on" is a VALUE question, and until issue #752 these two asked
3339
+ * a weaker one than `A23` — whether a `mix` key array was non-empty — so a
3340
+ * timeline keying 0 only was a rescue for a constraint it leaves exactly as
3341
+ * muted as no timeline does. Now a path constraint is switched on by a key
3342
+ * posing any of its three mixes above 0, which is the runtime's own
3343
+ * condition: `PathConstraint.update` returns when `mixRotate`, `mixX` and
3344
+ * `mixY` are all 0 (`PathConstraint.js:73-75`), and `Slider.update` when
3345
+ * `mix` is (`Slider.js:53-54`).
3269
3346
  */
3270
- const keyedBy = (group: string, name: string, timeline: string): boolean => {
3271
- if (!raw || !isObj(raw.animations)) return false;
3272
- for (const anim of Object.values(raw.animations as Json)) {
3273
- if (!isObj(anim) || !isObj(anim[group])) continue;
3274
- const timelines = (anim[group] as Json)[name];
3275
- if (!isObj(timelines)) continue;
3276
- const keys = timelines[timeline];
3277
- if (Array.isArray(keys) && keys.length > 0) return true;
3278
- }
3279
- return false;
3280
- };
3347
+ const pathSwitchedOn = keyedLive(
3348
+ (timeline): timeline is PathConstraintMixTimeline => timeline instanceof PathConstraintMixTimeline,
3349
+ (_timeline, value) => mixLive(value),
3350
+ );
3351
+ const sliderSwitchedOn = keyedLive(
3352
+ (timeline): timeline is SliderMixTimeline => timeline instanceof SliderMixTimeline,
3353
+ (_timeline, value) => mixLive(value),
3354
+ );
3281
3355
 
3282
3356
  // --- A36: a path constraint that follows nothing, quietly ---------------
3283
3357
  //
@@ -3293,9 +3367,9 @@ export function validate(input: ValidateInput): ValidateReport {
3293
3367
  // slot exists, the constraint resolves, the mixes are 1.
3294
3368
  //
3295
3369
  // The rest are the same shape as A23's: a constraint that parses and does
3296
- // nothing. All three mixes at 0 is only a finding when no animation keys the
3297
- // `mix` timeline (see `keyedBy`), and a chain with no bones on it is one
3298
- // whether or not anything is keyed.
3370
+ // nothing. All three mixes at 0 is only a finding when no animation keys one
3371
+ // of them above 0 (see `pathSwitchedOn`), and a chain with no bones on it is
3372
+ // one whether or not anything is keyed.
3299
3373
  check('A36_PATH_CONSTRAINT_EFFECTIVE', () => {
3300
3374
  const constraints = data.constraints.filter((c) => c instanceof PathConstraintData);
3301
3375
  if (!constraints.length) return skip('A36_PATH_CONSTRAINT_EFFECTIVE', 'the skeleton declares no path constraint');
@@ -3322,12 +3396,13 @@ export function validate(input: ValidateInput): ValidateReport {
3322
3396
  );
3323
3397
  }
3324
3398
  const pose = constraint.setupPose;
3325
- const muted = !(pose.mixRotate > 0) && !(pose.mixX > 0) && !(pose.mixY > 0);
3326
- if (muted && !keyedBy('path', constraint.name, 'mix')) {
3399
+ const muted = ![pose.mixRotate, pose.mixX, pose.mixY].some(mixLive);
3400
+ if (muted && !pathSwitchedOn.has(constraint)) {
3327
3401
  fail(
3328
3402
  'A36_PATH_CONSTRAINT_EFFECTIVE',
3329
- `${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and no ` +
3330
- 'animation keys its mix timeline; update() returns on all-zero mixes, so nothing ever puts a bone on the path',
3403
+ `${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and ` +
3404
+ `${noneKeysItsMixAbove0(data.animations.length)}; update() returns on all-zero mixes, so nothing ever ` +
3405
+ 'puts a bone on the path — rest one of the three above 0, or key its mix above 0 in an animation',
3331
3406
  );
3332
3407
  }
3333
3408
  }
@@ -3384,10 +3459,11 @@ export function validate(input: ValidateInput): ValidateReport {
3384
3459
  );
3385
3460
  }
3386
3461
  const mix = slider.setupPose.mix;
3387
- if (!(mix > 0) && !keyedBy('slider', slider.name, 'mix')) {
3462
+ if (!mixLive(mix) && !sliderSwitchedOn.has(slider)) {
3388
3463
  fail(
3389
3464
  'A37_SLIDER_CONSTRAINT_EFFECTIVE',
3390
- `${where} has mix ${mix} at setup and no animation keys its mix timeline; update() returns on mix 0`,
3465
+ `${where} has mix ${mix} at setup and ${noneKeysItsMixAbove0(data.animations.length)}; update() returns ` +
3466
+ `on mix 0 — ${REST_OR_KEY_ITS_MIX}`,
3391
3467
  );
3392
3468
  }
3393
3469
  }
@@ -3510,7 +3586,15 @@ export function validate(input: ValidateInput): ValidateReport {
3510
3586
  );
3511
3587
  }
3512
3588
  // Clause 1 of the "could this be correct?" list above.
3513
- const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !keyedBy('slider', s.name, 'mix'));
3589
+ // "Keyed at all" rather than "keyed live", and on purpose: this clause asks
3590
+ // whether the mix can MOVE from its setup value, so any mix timeline
3591
+ // disqualifies it — the question `keyedBy` answered here, through the one
3592
+ // reading `keyedLive` gives, and unchanged by issue #752.
3593
+ const mixKeyed = keyedLive(
3594
+ (timeline): timeline is SliderMixTimeline => timeline instanceof SliderMixTimeline,
3595
+ () => true,
3596
+ );
3597
+ const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !mixKeyed.has(s));
3514
3598
  if (authoritative.length < 2) {
3515
3599
  return skip(
3516
3600
  'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
@@ -3709,15 +3793,16 @@ export function validate(input: ValidateInput): ValidateReport {
3709
3793
  * A physics timeline whose animation names no constraint carries
3710
3794
  * `constraintIndex -1`, and `PhysicsConstraintTimeline.apply` reads that
3711
3795
  * as every ACTIVE physics constraint whose own data declares that property
3712
- * global. No rig spec reaches it — the compiler refuses a track naming an
3713
- * unknown physics constraint, the empty name among them — but a file rigc
3714
- * did not write can carry one, and this is the only assertion that would
3715
- * have to guess at it.
3796
+ * global. A motion spec spells it `"physics": "*"` (issue #726) and a
3797
+ * foreign file the empty name; `unnamedPhysicsReach` is the one reading
3798
+ * of it, shared with `A23` and `A34`.
3716
3799
  */
3717
3800
  const drivenBy = (timeline: Timeline & ConstraintTimeline): number[] => {
3718
3801
  if (timeline.constraintIndex >= 0) return [timeline.constraintIndex];
3719
- if (!(timeline instanceof PhysicsConstraintTimeline)) return [];
3720
- return data.constraints.flatMap((one, index) => (one instanceof PhysicsConstraintData && timeline.global(one) ? [index] : []));
3802
+ return unnamedPhysicsReach(
3803
+ timeline,
3804
+ data.constraints.filter((one) => one instanceof PhysicsConstraintData),
3805
+ ).map((one) => data.constraints.indexOf(one));
3721
3806
  };
3722
3807
  let pairs = 0;
3723
3808
  let resets = 0;
@@ -3931,6 +4016,64 @@ export function validate(input: ValidateInput): ValidateReport {
3931
4016
  // static rig has no duration to compare — and the two must not print
3932
4017
  // different verdicts over one skeleton's empty animation list.
3933
4018
  if (data.animations.length === 0) return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_ANIMATION);
4019
+ /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
4020
+ const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
4021
+
4022
+ // -- the bone's inheritance mode, posed at every `inherit` key ---------
4023
+ //
4024
+ // 🚨 The one bone timeline whose value is a NAME, and the only one that
4025
+ // can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
4026
+ // through `Utils.enumValue`, which folds the first letter's case and
4027
+ // nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
4028
+ // `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
4029
+ // `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
4030
+ // and the bone keeps whatever world matrix it had — measured on a forged
4031
+ // two-bone chain: at the key the child's `a,b,c,d` equal the setup
4032
+ // Normal-mode pose to the last digit while the file says `noScale`. The
4033
+ // world position stays finite, so the loop below never saw it (#733).
4034
+ //
4035
+ // ⚠️ Posed AT each key rather than read off the stepping loop, because a
4036
+ // stepped value lives from its key to the next one and a sampling grid
4037
+ // can step over a short span entirely. What is judged is whether the
4038
+ // posed value IS a mode — which is this assertion's name exactly — and
4039
+ // not which mode: for any spelling the lookup resolves, the mode posed
4040
+ // is the mode written by construction of the same lookup, so an equality
4041
+ // here would be the parser agreeing with itself.
4042
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
4043
+ let unresolved = 0;
4044
+ for (const [animName, rawAnim] of Object.entries(rawAnimations)) {
4045
+ if (!isObj(rawAnim) || !isObj(rawAnim.bones)) continue;
4046
+ for (const [boneName, timelines] of Object.entries(rawAnim.bones)) {
4047
+ if (!isObj(timelines) || !Array.isArray(timelines.inherit)) continue;
4048
+ if (!data.findAnimation(animName) || !data.findBone(boneName)) continue;
4049
+ for (const rawKey of timelines.inherit as unknown[]) {
4050
+ if (!isObj(rawKey)) continue;
4051
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
4052
+ const skeleton = new Skeleton(data);
4053
+ const state = new AnimationState(new AnimationStateData(data));
4054
+ state.setAnimation(0, animName, false);
4055
+ skeleton.setupPose();
4056
+ skeleton.update(0);
4057
+ skeleton.updateWorldTransform(Physics.reset);
4058
+ state.update(time);
4059
+ state.apply(skeleton);
4060
+ skeleton.update(time);
4061
+ skeleton.updateWorldTransform(Physics.update);
4062
+ const posed = skeleton.findBone(boneName)?.appliedPose.inherit;
4063
+ if (isMode(posed)) continue;
4064
+ unresolved++;
4065
+ fail(
4066
+ 'A10_NO_NAN_AFTER_STEPPING',
4067
+ `animation "${animName}" bone "${boneName}" inherit (t=${time}): the bone poses inheritance mode ` +
4068
+ `${String(posed)} — the key spells ${JSON.stringify(rawKey.inherit)}, which the runtime's mode lookup ` +
4069
+ `does not resolve (${BONE_INHERIT_KNOWN}), so no mode applies until the next key and the bone keeps ` +
4070
+ 'the world rotation, scale and shear it had',
4071
+ );
4072
+ }
4073
+ }
4074
+ }
4075
+ if (unresolved > 0) return;
4076
+
3934
4077
  for (const anim of data.animations) {
3935
4078
  const skeleton = new Skeleton(data);
3936
4079
  const state = new AnimationState(new AnimationStateData(data));
@@ -3950,6 +4093,23 @@ export function validate(input: ValidateInput): ValidateReport {
3950
4093
  fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: bone "${bone.data.name}" world is (${pose.worldX}, ${pose.worldY})`);
3951
4094
  return;
3952
4095
  }
4096
+ // The setup half of the clause above: a bone's own `inherit` goes
4097
+ // through the same lookup, and a miss there loads `undefined` into
4098
+ // the setup pose, which every frame copies. Only reachable on a
4099
+ // file rigc did not write — `parseRigSpec` refuses the spelling.
4100
+ if (!isMode(pose.inherit)) {
4101
+ const rawBone = Array.isArray(raw?.bones)
4102
+ ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
4103
+ : undefined;
4104
+ fail(
4105
+ 'A10_NO_NAN_AFTER_STEPPING',
4106
+ `${anim.name}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
4107
+ `spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
4108
+ `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
4109
+ 'are never computed — they stay 0 and everything the bone carries collapses to a point',
4110
+ );
4111
+ return;
4112
+ }
3953
4113
  }
3954
4114
  for (const slot of skeleton.slots) {
3955
4115
  const c = slot.appliedPose.color;
@@ -4420,6 +4580,259 @@ export function validate(input: ValidateInput): ValidateReport {
4420
4580
  );
4421
4581
  }
4422
4582
  });
4583
+
4584
+ // --- A46: a numbered series shows the frame the file states ------------
4585
+ //
4586
+ // 🚨 Every way this goes wrong loads without a word (issue #729, measured on
4587
+ // spine-core 4.3.13): a `sequence` block with no `count` loads a series of
4588
+ // no region; a `setup` past the end is clamped to the last frame; a key's
4589
+ // `mode` outside the seven loads as `hold`; an `index` past the end is
4590
+ // clamped and a fraction truncated; an advancing mode at an effective delay
4591
+ // of 0 never advances; and a `sequence` timeline on an attachment with no
4592
+ // block steps a one-region series and shows that region under every mode.
4593
+ //
4594
+ // ⭐ Two halves, and the second is the one only posing can see. The first
4595
+ // reads each of those off the FILE and names the value. The second POSES
4596
+ // every key at sampled times — mid-frame, so a float32 key time cannot land
4597
+ // a sample on a frame boundary — and compares the region the slot shows
4598
+ // against the one the file's own statement gives: the frame arithmetic of
4599
+ // `SequenceTimeline.applyToSlot` transcribed here, and the frame names of
4600
+ // `Sequence.getPath` transcribed in `attachmentRegionLookups`. Neither is
4601
+ // read off the loaded timeline, so the check is not the runtime agreeing
4602
+ // with itself.
4603
+ //
4604
+ // ⚠️ A sample where the slot shows some other attachment is not compared —
4605
+ // `applyToSlot` returns there, and a series that is hidden is not a series
4606
+ // showing the wrong frame. A timeline with no comparable sample at all is
4607
+ // counted in `stats.sequenceSamplesUnshown` rather than failed.
4608
+ check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => {
4609
+ const NAME = 'A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES';
4610
+ type Series = { where: string; lookups: string[] | null; count: number; setup: number };
4611
+ /** The loaded attachment -> what the FILE says its series is. */
4612
+ const series = new Map<object, Series>();
4613
+ /** `skin\0slot\0placeholder` -> the loaded attachment, for the timelines to find. */
4614
+ const byJoin = new Map<string, object>();
4615
+ /** Attachments whose block was already refused above — their timelines say nothing more. */
4616
+ const refused = new Set<object>();
4617
+ const whole = (v: unknown, fallback: number): number | null =>
4618
+ v === undefined ? fallback : typeof v === 'number' && Number.isInteger(v) ? v : null;
4619
+ let blocks = 0;
4620
+ for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
4621
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
4622
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
4623
+ const loadedSkin = data.findSkin(skinName);
4624
+ for (const [slotName, entries] of Object.entries(skin.attachments)) {
4625
+ if (!isObj(entries)) continue;
4626
+ const slotIndex = data.findSlot(slotName)?.index;
4627
+ for (const [placeholder, entry] of Object.entries(entries)) {
4628
+ if (!isObj(entry)) continue;
4629
+ const type = entry.type === undefined ? 'region' : entry.type;
4630
+ if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
4631
+ const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
4632
+ const where = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(placeholder)}`;
4633
+ if (loaded !== null) byJoin.set(`${skinName}\u0000${slotName}\u0000${placeholder}`, loaded);
4634
+ if (entry.sequence === undefined || entry.sequence === null) continue;
4635
+ blocks++;
4636
+ const seq = entry.sequence;
4637
+ const name = typeof entry.name === 'string' ? entry.name : placeholder;
4638
+ const path = typeof entry.path === 'string' ? entry.path : name;
4639
+ const count = isObj(seq) ? whole(seq.count, 0) : null;
4640
+ const setup = isObj(seq) ? whole(seq.setup, 0) : null;
4641
+ if ((count === null || setup === null || count < 1) && loaded !== null) refused.add(loaded);
4642
+ if (count === null || setup === null || count < 1) {
4643
+ fail(
4644
+ NAME,
4645
+ `${where}: the sequence states ${JSON.stringify(seq)}` +
4646
+ (isObj(seq) && seq.count === undefined
4647
+ ? ' and no "count" — `readSequence` reads 0 (`SkeletonJson.js:644`), so the attachment loads holding no region and draws nothing'
4648
+ : ' — "count" is a whole number of at least 1 and "setup" a whole number, or the series the parser builds is not the one written'),
4649
+ );
4650
+ continue;
4651
+ }
4652
+ if ((setup < 0 || setup >= count) && loaded !== null) refused.add(loaded);
4653
+ if (setup < 0 || setup >= count) {
4654
+ fail(
4655
+ NAME,
4656
+ `${where}: the sequence's setup frame is ${setup} of a ${count}-frame series (frames 0 to ${count - 1}); ` +
4657
+ '`Sequence.resolveIndex` clamps it, so the setup pose shows ' +
4658
+ `${setup < 0 ? 'no frame at all' : `frame ${count - 1}, which the file does not name`}`,
4659
+ );
4660
+ continue;
4661
+ }
4662
+ if (loaded === null) continue; // A00/A08 own an attachment that did not load
4663
+ series.set(loaded, { where, lookups: attachmentRegionLookups(seq, path), count, setup });
4664
+ }
4665
+ }
4666
+ }
4667
+
4668
+ /** The frame `SequenceTimeline.applyToSlot` shows — transcribed, not called. */
4669
+ const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
4670
+ if (mode === 'hold') return index;
4671
+ let i = index + Math.trunc(elapsed / delay + 0.00001);
4672
+ const n = count * 2 - 2;
4673
+ switch (mode) {
4674
+ case 'once':
4675
+ return Math.min(count - 1, i);
4676
+ case 'loop':
4677
+ return i % count;
4678
+ case 'pingpong':
4679
+ i = n === 0 ? 0 : i % n;
4680
+ return i >= count ? n - i : i;
4681
+ case 'onceReverse':
4682
+ return Math.max(count - 1 - i, 0);
4683
+ case 'loopReverse':
4684
+ return count - 1 - (i % count);
4685
+ default: // pingpongReverse
4686
+ i = n === 0 ? 0 : (i + count - 1) % n;
4687
+ return i >= count ? n - i : i;
4688
+ }
4689
+ };
4690
+
4691
+ let timelines = 0;
4692
+ let compared = 0;
4693
+ let unshown = 0;
4694
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
4695
+ for (const [animName, anim] of Object.entries(rawAnimations)) {
4696
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
4697
+ for (const [skinName, perSkin] of Object.entries(anim.attachments)) {
4698
+ if (!isObj(perSkin)) continue;
4699
+ for (const [slotName, perSlot] of Object.entries(perSkin)) {
4700
+ if (!isObj(perSlot)) continue;
4701
+ for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
4702
+ if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
4703
+ const keys = perAttachment.sequence as unknown[];
4704
+ if (keys.length === 0) continue; // `readAnimation` skips it; A34 owns an empty timeline
4705
+ timelines++;
4706
+ const where = `animation ${JSON.stringify(animName)} ${skinName}/${slotName}/${placeholder} sequence`;
4707
+ const keyed = byJoin.get(`${skinName}\u0000${slotName}\u0000${placeholder}`);
4708
+ if (keyed === undefined) continue; // the parser throws on a missing target: A00's
4709
+ if (refused.has(keyed)) continue; // its block is already named above
4710
+ const own = series.get(keyed);
4711
+ if (own === undefined) {
4712
+ fail(
4713
+ NAME,
4714
+ `${where}: the timeline steps an attachment that carries no "sequence" block. The parser gives it a ` +
4715
+ 'series of ONE region (`readSequence(null)` is `new Sequence(1, false)`), so every mode shows that ' +
4716
+ 'region at every time — measured: a "loop" key on a plain region showed it throughout',
4717
+ );
4718
+ continue;
4719
+ }
4720
+ // -- the keys, as the file states them --------------------------
4721
+ type Key = { time: number; mode: string; index: number; delay: number };
4722
+ const read: Key[] = [];
4723
+ let carried = 0;
4724
+ let malformed = false;
4725
+ keys.forEach((rawKey, k) => {
4726
+ const key = isObj(rawKey) ? rawKey : {};
4727
+ const at = `${where} key ${k}`;
4728
+ const time = typeof key.time === 'number' ? key.time : 0;
4729
+ const mode = key.mode === undefined ? 'hold' : key.mode;
4730
+ const index = key.index === undefined ? 0 : key.index;
4731
+ if (key.delay !== undefined) carried = typeof key.delay === 'number' ? key.delay : Number.NaN;
4732
+ if (typeof mode !== 'string' || !(SEQUENCE_MODES as readonly string[]).includes(mode)) {
4733
+ malformed = true;
4734
+ fail(
4735
+ NAME,
4736
+ `${at} (t=${time}): mode ${JSON.stringify(key.mode)} is not one of the ${SEQUENCE_MODES.length} the ` +
4737
+ `format has (${SEQUENCE_MODES.join(', ')}); the parser reads \`SequenceMode[mode]\`, which is ` +
4738
+ 'undefined, stores mode bits 0, and the key plays as "hold"',
4739
+ );
4740
+ return;
4741
+ }
4742
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= own.count) {
4743
+ malformed = true;
4744
+ fail(
4745
+ NAME,
4746
+ `${at} (t=${time}): index ${JSON.stringify(key.index)} is not a frame of the ${own.count}-frame series ` +
4747
+ `(0 to ${own.count - 1}); the runtime stores \`index << 4\`, truncating a fraction, and ` +
4748
+ '`Sequence.resolveIndex` clamps a frame past the end to the last one',
4749
+ );
4750
+ return;
4751
+ }
4752
+ if (mode !== 'hold' && !(carried > 0)) {
4753
+ malformed = true;
4754
+ fail(
4755
+ NAME,
4756
+ `${at} (t=${time}): "${mode}" at a delay of ${String(carried)}${key.delay === undefined ? ' (carried from the key before, 0 on the first)' : ''} — ` +
4757
+ 'the frame advances by `(time - keyTime) / delay`, and at 0 that is Infinity, `Infinity | 0` is 0, ' +
4758
+ 'and the key shows its first frame throughout: "hold" spelt as another mode',
4759
+ );
4760
+ return;
4761
+ }
4762
+ read.push({ time, mode, index, delay: carried });
4763
+ });
4764
+ if (malformed) continue;
4765
+
4766
+ // -- the pose, sampled ------------------------------------------
4767
+ const animation = data.findAnimation(animName);
4768
+ const slotIndex = data.findSlot(slotName)?.index;
4769
+ if (animation === null || slotIndex === undefined) continue; // A00's
4770
+ /** `key` is the index into `read`, or -1 before the first key (the setup frame). */
4771
+ const samples: Array<{ time: number; key: number; steps: number }> = [];
4772
+ const end = animation.duration;
4773
+ if (read[0].time > 0) samples.push({ time: read[0].time / 2, key: -1, steps: 0 });
4774
+ read.forEach((key, k) => {
4775
+ const until = k + 1 < read.length ? read[k + 1].time : end;
4776
+ if (key.mode === 'hold') {
4777
+ samples.push({ time: key.time, key: k, steps: 0 });
4778
+ return;
4779
+ }
4780
+ // Mid-frame, and enough steps to wrap every mode at least once.
4781
+ for (let step = 0; step < own.count * 2 + 2; step++) {
4782
+ const time = key.time + (step + 0.5) * key.delay;
4783
+ if (time >= until || time > end) break;
4784
+ samples.push({ time, key: k, steps: step + 0.5 });
4785
+ }
4786
+ });
4787
+ let shownHere = 0;
4788
+ for (const sample of samples) {
4789
+ const skeleton = new Skeleton(data);
4790
+ const state = new AnimationState(new AnimationStateData(data));
4791
+ state.setAnimation(0, animName, false);
4792
+ skeleton.setupPose();
4793
+ skeleton.update(0);
4794
+ skeleton.updateWorldTransform(Physics.reset);
4795
+ state.update(sample.time);
4796
+ state.apply(skeleton);
4797
+ const pose = skeleton.slots[slotIndex].appliedPose;
4798
+ const shown = pose.attachment;
4799
+ // The slot must show the keyed attachment or one playing its
4800
+ // timelines — the only case `applyToSlot` writes.
4801
+ if (shown === null || (shown !== keyed && shown.timelineAttachment !== keyed)) continue;
4802
+ const drawn = series.get(shown);
4803
+ if (drawn === undefined || drawn.lookups === null) continue;
4804
+ shownHere++;
4805
+ compared++;
4806
+ // The frame count the runtime folds by is the SHOWN attachment's:
4807
+ // a link with a series of its own steps it by its source's keys.
4808
+ const key = sample.key < 0 ? null : read[sample.key];
4809
+ const want = key === null ? drawn.setup : frameOf(key.mode, key.index, sample.time - key.time, key.delay, drawn.count);
4810
+ const sequence = (shown as RegionAttachment | MeshAttachment).sequence;
4811
+ const region = (sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null;
4812
+ if (region !== drawn.lookups[want]) {
4813
+ fail(
4814
+ NAME,
4815
+ `${where} (t=${Number(sample.time.toFixed(4))}): the slot shows region ${JSON.stringify(region)}, and ` +
4816
+ `the file states frame ${want} of ${drawn.count} — ${JSON.stringify(drawn.lookups[want])} — ` +
4817
+ (key === null
4818
+ ? 'the setup frame, before the first key'
4819
+ : `key ${sample.key} plays "${key.mode}" from frame ${key.index} every ${key.delay}s, ` +
4820
+ `${sample.steps} delay(s) in`),
4821
+ );
4822
+ break;
4823
+ }
4824
+ }
4825
+ if (shownHere === 0) unshown++;
4826
+ }
4827
+ }
4828
+ }
4829
+ }
4830
+ if (blocks === 0 && timelines === 0) return skip(NAME, SKIP_NO_SEQUENCE);
4831
+ stats.sequenceBlocks = blocks;
4832
+ stats.sequenceTimelines = timelines;
4833
+ stats.sequenceSamples = compared;
4834
+ if (unshown > 0) stats.sequenceSamplesUnshown = unshown;
4835
+ });
4423
4836
  }
4424
4837
 
4425
4838
  // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
@@ -4486,7 +4899,9 @@ export function validate(input: ValidateInput): ValidateReport {
4486
4899
  );
4487
4900
  }
4488
4901
  const info = header.info;
4489
- if (info !== null && (page.width !== info.width || page.height !== info.height)) {
4902
+ const onPage = atlas.regions.filter((region) => region.page.name === page.name);
4903
+ const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
4904
+ if (gridSaid !== null) {
4490
4905
  // 🔒 **Still validity, and now for a measured reason rather than an
4491
4906
  // inherited one** (issue #715). The card that opened this expected the
4492
4907
  // clause to move behind the profile switch once the runtime's mapping
@@ -4494,29 +4909,11 @@ export function validate(input: ValidateInput): ValidateReport {
4494
4909
  // draws. What settles it the other way is that the sentence below can
4495
4910
  // name a repair the FORMAT already provides (`scale:`), and that two of
4496
4911
  // the three readers a wrong grid breaks are rigc's own and run under
4497
- // both profiles. See `pageGridReading` for the routines and the figures.
4498
- const grid = pageGridReading(page, info);
4499
- const said = `page "${page.name}" declares ${page.width}x${page.height} and its PNG is ${info.width}x${info.height}`;
4500
- if (grid === null) {
4501
- fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${said}. ${PAGE_GRID_PREAMBLE}`);
4502
- } else {
4503
- const ratios =
4504
- `${said} — ${gridRatio(grid.x)} of the declared width and ${gridRatio(grid.y)} of the declared ` +
4505
- 'height.';
4506
- const onPage = atlas.regions.filter((region) => region.page.name === page.name);
4507
- const off = grid.uniform ? firstNumberOffTheCoarseGrid(onPage, page.width, info.width) : null;
4508
- const repair = !grid.uniform
4509
- ? 'The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ' +
4510
- `declared honestly at all: re-export the page at ${page.width}x${page.height}, or repack it.`
4511
- : off !== null
4512
- ? 'The format states coarser texels with the `scale:` header, but this page cannot be re-declared ' +
4513
- `that way: ${off}. Re-export the page at ${page.width}x${page.height}, or repack it.`
4514
- : 'The format states coarser texels with the `scale:` header and rigc builds that: declare ' +
4515
- `\`size: ${info.width}, ${info.height}\` with \`scale: ${gridRatio(grid.x)}\` and multiply every ` +
4516
- `\`bounds\`/\`offsets\` on this page by ${gridRatio(grid.x)}, and every part keeps the size it ` +
4517
- 'has now.';
4518
- fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${ratios} ${PAGE_GRID_PREAMBLE} ${repair}`);
4519
- }
4912
+ // both profiles. The sentence, and the one derivation of the ratio under
4913
+ // it, is `pageGridSentence` in [`src/atlas.ts`](atlas.ts): the compiler's
4914
+ // region lift states the same one when it is asked for texels on such a
4915
+ // page (issue #750), so the two cannot come to describe one page two ways.
4916
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', gridSaid);
4520
4917
  }
4521
4918
  // 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
4522
4919
  // CONVENTION, not the atlas format's rules — a packed page with