spine-rigc 1.9.0 → 1.9.2

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.
Files changed (62) hide show
  1. package/cli.ts +48 -17
  2. package/docs/AUTHORING.md +2 -2
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a09.ts +82 -0
  5. package/src/assertions/bodies/a10.ts +116 -0
  6. package/src/assertions/bodies/a12.ts +30 -0
  7. package/src/assertions/bodies/a24.ts +68 -0
  8. package/src/assertions/bodies/a25.ts +39 -0
  9. package/src/assertions/bodies/a26.ts +61 -0
  10. package/src/assertions/bodies/a29.ts +34 -0
  11. package/src/assertions/bodies/a30.ts +50 -0
  12. package/src/assertions/bodies/a32.ts +44 -0
  13. package/src/assertions/bodies/a34.ts +133 -0
  14. package/src/assertions/bodies/a39.ts +303 -0
  15. package/src/assertions/bodies/a40.ts +128 -0
  16. package/src/assertions/bodies/a43.ts +181 -0
  17. package/src/assertions/bodies/a46.ts +224 -0
  18. package/src/assertions/facts/animation_durations.ts +37 -0
  19. package/src/assertions/facts/bone_timelines.ts +37 -0
  20. package/src/assertions/facts/constraint_targets.ts +56 -0
  21. package/src/assertions/facts/deform_survey.ts +27 -0
  22. package/src/assertions/facts/event_keys.ts +55 -0
  23. package/src/assertions/facts/sequences.ts +85 -0
  24. package/src/assertions/facts/skeleton_roster.ts +45 -0
  25. package/src/assertions/facts/slider_composition.ts +78 -0
  26. package/src/assertions/facts/stepped_poses.ts +74 -0
  27. package/src/assertions/facts/two_colour.ts +52 -0
  28. package/src/assertions/inward_advance.ts +58 -0
  29. package/src/assertions/model/animation_durations.ts +57 -0
  30. package/src/assertions/model/bone_timelines.ts +56 -0
  31. package/src/assertions/model/constraint_targets.ts +82 -0
  32. package/src/assertions/model/constraints.ts +34 -16
  33. package/src/assertions/model/deform_survey.ts +24 -0
  34. package/src/assertions/model/event_keys.ts +45 -0
  35. package/src/assertions/model/index.ts +83 -2
  36. package/src/assertions/model/linked_meshes.ts +2 -2
  37. package/src/assertions/model/mesh_attachments.ts +1 -1
  38. package/src/assertions/model/region_joins.ts +20 -12
  39. package/src/assertions/model/sequences.ts +157 -0
  40. package/src/assertions/model/skeleton_roster.ts +23 -0
  41. package/src/assertions/model/skin_entries.ts +18 -8
  42. package/src/assertions/model/skin_members.ts +5 -3
  43. package/src/assertions/model/skin_meshes.ts +1 -1
  44. package/src/assertions/model/slider_composition.ts +191 -0
  45. package/src/assertions/model/slot_colour.ts +5 -4
  46. package/src/assertions/model/stepped_poses.ts +89 -0
  47. package/src/assertions/model/two_colour.ts +61 -0
  48. package/src/assertions/model/vertex_polygons.ts +9 -8
  49. package/src/bonedist.ts +18 -6
  50. package/src/compile.ts +114 -14
  51. package/src/core/additive.ts +468 -0
  52. package/src/core/constraints.ts +12 -2
  53. package/src/core/constraints_physics.ts +31 -8
  54. package/src/core/raw.ts +77 -16
  55. package/src/core/walk.ts +202 -0
  56. package/src/deformmeasure.ts +35 -2270
  57. package/src/deformstructure.ts +60 -18
  58. package/src/deformsurvey.ts +2295 -0
  59. package/src/nonfinite.ts +54 -0
  60. package/src/render.ts +200 -59
  61. package/src/render_core.ts +5 -0
  62. package/src/validate.ts +791 -1558
package/src/validate.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { dirname, resolve } from 'node:path';
17
17
  import {
18
+ type Animation,
18
19
  AnimationState,
19
20
  AnimationStateData,
20
21
  AtlasAttachmentLoader,
@@ -40,6 +41,7 @@ import {
40
41
  PhysicsConstraintTimeline,
41
42
  Property,
42
43
  RegionAttachment,
44
+ SequenceTimeline as RuntimeSequenceTimeline,
43
45
  Skeleton,
44
46
  SkeletonJson,
45
47
  SliderData,
@@ -58,36 +60,25 @@ import {
58
60
  // them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
59
61
  // A19 needs the DECODED page, not its header, to measure one region's own
60
62
  // rectangle on a shared page.
61
- import {
62
- BEZIER_POINTS,
63
- curveStorage,
64
- surveyDeformKeys,
65
- unreachableWhy,
66
- type DeformDialDispute,
67
- type DeformDialTie,
68
- type DeformReach,
69
- type DialSpan,
70
- } from './deformmeasure.ts';
63
+ import { BEZIER_POINTS, curveStorage, surveyDeformKeys } from './deformmeasure.ts';
71
64
  import {
72
65
  LEGACY_BONE_INHERIT_KEY,
73
66
  spineGeneration,
74
67
  TOPLEVEL_CONSTRAINT_ARRAYS,
75
68
  type SpineGeneration,
76
69
  } from './generation.ts';
77
- import { nonFiniteOfPosed } from './render.ts';
70
+ import { posedNumbersOf } from './render.ts';
78
71
  import { BONE_INHERIT_KNOWN } from './rig.ts';
79
72
  import {
80
73
  CHANNELS_BY_KIND,
81
- float32Step,
82
74
  physicsRuleFor,
83
- SEQUENCE_MODES,
84
75
  SLOT_COLOR_CHANNELS,
85
76
  walkTimelines,
86
77
  } from './timelines.ts';
87
78
  import type { RigInfo } from './types.ts';
88
79
  import { ASSERTION_KIND, kindRunsUnder, type AssertionProfile } from './assertions/kinds.ts';
89
80
  import { verdictHarness, type Failure } from './assertions/harness.ts';
90
- import { atStoredKey, isObj, type Json } from './assertions/values.ts';
81
+ import { isObj, type Json } from './assertions/values.ts';
91
82
  import type { SkinEntryFacts } from './assertions/facts/skin_entries.ts';
92
83
  import type { AtlasPageFacts } from './assertions/facts/atlas_pages.ts';
93
84
  import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_colour.ts';
@@ -129,18 +120,39 @@ import { a42DrivenConstraintsUpdateAfterTheirDriver } from './assertions/bodies/
129
120
  import { a44LinkedMeshStatesNoGeometryOfItsOwn } from './assertions/bodies/a44.ts';
130
121
  import { a47IkConstraintNotMutedThroughout } from './assertions/bodies/a47.ts';
131
122
  import { a48TransformConstraintNotMutedThroughout } from './assertions/bodies/a48.ts';
123
+ import type { RosterBone, RosterSlot, SkeletonRosterFacts } from './assertions/facts/skeleton_roster.ts';
124
+ import type { BoneTimelineFacts, BoneTimelines } from './assertions/facts/bone_timelines.ts';
125
+ import type { EventKeyEntry, EventKeyFacts } from './assertions/facts/event_keys.ts';
126
+ import { a12NoDarkColor } from './assertions/bodies/a12.ts';
127
+ import { a24AxisSpaceStroke } from './assertions/bodies/a24.ts';
128
+ import { a25DetachedBoneParentage } from './assertions/bodies/a25.ts';
129
+ import { a26SlotDrawOrder } from './assertions/bodies/a26.ts';
130
+ import { a29StrokeWithinContactDepth } from './assertions/bodies/a29.ts';
131
+ import { a30StrokeWithinCapContainment } from './assertions/bodies/a30.ts';
132
+ import { a32EventKeysResolve, eventKeyAt } from './assertions/bodies/a32.ts';
132
133
  import {
133
134
  SKIP_NO_ANIMATION,
134
135
  SKIP_NO_ATLAS,
135
136
  SKIP_NO_ATLAS_PAGE,
136
- SKIP_NO_DECLARED_DURATION,
137
137
  SKIP_NO_MESH_ATTACHMENT,
138
138
  SKIP_NO_POSE,
139
- SKIP_NO_SEQUENCE,
140
139
  SKIP_NO_SKELETON,
141
140
  SKIP_NO_TIMELINE,
142
- SKIP_NO_TWO_COLOR_TINT,
143
141
  } from './assertions/reasons.ts';
142
+ import type { DeformSurveyFacts } from './assertions/facts/deform_survey.ts';
143
+ import type { AnimationDurationFacts } from './assertions/facts/animation_durations.ts';
144
+ import type { TwoColourFacts } from './assertions/facts/two_colour.ts';
145
+ import { entryAddress, type EntryAddress, type SequenceFacts, type SequenceSkinEntry, type SequenceTimeline } from './assertions/facts/sequences.ts';
146
+ import { a39DeformKeepsTriangleWinding } from './assertions/bodies/a39.ts';
147
+ import { a09AnimationDurationMatchesSpec } from './assertions/bodies/a09.ts';
148
+ import { a43TwoColorTintLoadsAndPosesAsWritten } from './assertions/bodies/a43.ts';
149
+ import { a46SequenceAttachmentsShowTheFrameTheFileStates } from './assertions/bodies/a46.ts';
150
+ import type { SliderCompositionFacts, SliderFact, SliderTimelineFact } from './assertions/facts/slider_composition.ts';
151
+ import type { ConstraintTargetFacts, TargetAnimation, TargetKeyArray } from './assertions/facts/constraint_targets.ts';
152
+ import { a34ConstraintTimelineTargets } from './assertions/bodies/a34.ts';
153
+ import { a40SlidersComposeOnASharedTarget } from './assertions/bodies/a40.ts';
154
+ import { a10NoNanAfterStepping } from './assertions/bodies/a10.ts';
155
+ import type { SteppedFrame, SteppedPoseFacts } from './assertions/facts/stepped_poses.ts';
144
156
 
145
157
  export type { Failure } from './assertions/harness.ts';
146
158
 
@@ -284,62 +296,9 @@ export interface ValidateReport {
284
296
  stats: Record<string, number | string>;
285
297
  }
286
298
 
287
- /**
288
- * The clause A39 puts after an animation's name when the frame it measured is
289
- * not the track (issue #407).
290
- *
291
- * Empty on the track, which is what every animation no slider applies gets — so
292
- * a message about a rig with no sliders in it reads exactly as it always has.
293
- */
294
- function frameClause(reach: DeformReach): string {
295
- return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
296
- }
297
-
298
- /**
299
- * A dial's reach as A39's stats line spells it, or `none` when it can select no
300
- * part of its animation at all.
301
- *
302
- * Six decimals because a key time has six: a reach whose end is printed coarser
303
- * than the times it is compared against cannot be read against them.
304
- */
305
- function dialSpanText(span: DialSpan | null): string {
306
- return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
307
- }
308
-
309
- /**
310
- * One disputed dial on A39's stats line — both answers, both reaches, and the
311
- * frames the artifact's answer could not have posed (issue #427).
312
- *
313
- * ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
314
- * what decides whether a disagreement changed anything the survey measured, and a
315
- * comparison that came out equal must not look like one nobody made. It is the
316
- * difference between "both answers pose the same frames, so the disagreement is a
317
- * fact about rigc and not about this rig" and "these key times were surveyed
318
- * through a field the skeleton does not name and no settable value of the one it
319
- * does reaches them".
320
- *
321
- * ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
322
- * a value with a space in it turns one reading into two.
323
- */
324
- function dialDisputeText(dispute: DeformDialDispute): string {
325
- const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
326
- return (
327
- `${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
328
- `|reaches:${dialSpanText(dispute.statedReach)}` +
329
- `|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
330
- `|reaches:${dialSpanText(dispute.driveReach)}` +
331
- `|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
332
- );
333
- }
334
-
335
- /** One tied dial on the same line, in the shape that cannot be read as a dispute. */
336
- function dialTieText(tie: DeformDialTie): string {
337
- const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
338
- return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
339
- }
340
-
341
- const FRAME = 1 / 60;
342
- const STEP_FRAMES = 120;
299
+ // The four sentences A39 prints a frame, a dial's reach, a dispute and a tie
300
+ // with, and A09's one-frame slack, are `./assertions/bodies/a39.ts`'s and
301
+ // `./assertions/bodies/a09.ts`'s since issue #1025 (cut 4c-3).
343
302
 
344
303
  /**
345
304
  * The constraint groups that spell `group.<constraint>.<timeline>` — a
@@ -696,18 +655,18 @@ export type TimelineAddBehaviour = 'accumulates' | 'overwrites' | 'inert';
696
655
  */
697
656
  const PROBE_DISPLACEMENT = 0.375;
698
657
 
699
- /** Every own value of a pose object a timeline could have written, as text. */
700
- function poseReading(pose: object): string {
701
- const parts: string[] = [];
658
+ /** Every own value of a pose object a timeline could have written, each as its key and its text. */
659
+ function poseParts(pose: object): Array<[string, string]> {
660
+ const parts: Array<[string, string]> = [];
702
661
  for (const key of Object.keys(pose).sort()) {
703
662
  const value = (pose as Record<string, unknown>)[key];
704
663
  if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
705
- parts.push(`${key}=${String(value)}`);
664
+ parts.push([key, String(value)]);
706
665
  continue;
707
666
  }
708
667
  if (Array.isArray(value)) {
709
668
  const entries: unknown[] = value;
710
- if (entries.every((one) => typeof one === 'number')) parts.push(`${key}=[${entries.join(',')}]`);
669
+ if (entries.every((one) => typeof one === 'number')) parts.push([key, `[${entries.join(',')}]`]);
711
670
  continue;
712
671
  }
713
672
  if (typeof value !== 'object') continue;
@@ -717,12 +676,17 @@ function poseReading(pose: object): string {
717
676
  // structure — a bone back-reference, a slot's data — is either an identity
718
677
  // (read by name below) or something no timeline writes.
719
678
  if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
720
- parts.push(`${key}={${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`);
679
+ parts.push([key, `{${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`]);
721
680
  } else if (typeof record.name === 'string') {
722
- parts.push(`${key}=@${record.name}`);
681
+ parts.push([key, `@${record.name}`]);
723
682
  }
724
683
  }
725
- return parts.join(',');
684
+ return parts;
685
+ }
686
+
687
+ /** Every own value of a pose object a timeline could have written, as text. */
688
+ function poseReading(pose: object): string {
689
+ return poseParts(pose).map(([key, text]) => `${key}=${text}`).join(',');
726
690
  }
727
691
 
728
692
  /** The whole of what a timeline could have changed, in one comparable string. */
@@ -848,6 +812,73 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
848
812
  return wrote ? 'overwrites' : 'inert';
849
813
  }
850
814
 
815
+ /** One skin under one start state of `timelineAddBehaviour`'s probe: what the cell reads, and every pose field the applications moved. */
816
+ export interface TimelineAddCell {
817
+ /** The skin worn, `(none)` for no skin set. */
818
+ view: string;
819
+ state: 'setup' | 'displaced';
820
+ /** `A` a second application moved the pose at some time, `W` only the first did, `-` neither ever did. */
821
+ letter: 'A' | 'W' | '-';
822
+ /** Each field the first or the second application moved, at each time: `bone:<name>.<key>`, `slot:<name>.<key>` or `constraint:<name>.<key>`, with its three readings. */
823
+ changed: Array<{ field: string; t: number; before: string; once: string; twice: string }>;
824
+ }
825
+
826
+ /**
827
+ * `timelineAddBehaviour`'s probe, every cell kept: the same skins, start states,
828
+ * times and arguments, read per field rather than as one string — what the
829
+ * core suite's `CO27` holds the core's additive probe (`src/core/additive.ts`)
830
+ * to, cell by cell and value by value (issue #1025, cut 4c-5). The class the
831
+ * cells read is held to `timelineAddBehaviour`'s own answer on every timeline
832
+ * there, so the two cannot drift.
833
+ */
834
+ export function timelineAddCells(data: ReturnType<SkeletonJson['readSkeletonData']>, timeline: Timeline): TimelineAddCell[] {
835
+ const fields = (skeleton: Skeleton): Map<string, string> => {
836
+ const out = new Map<string, string>();
837
+ const take = (kind: string, name: string, pose: object): void => {
838
+ for (const [key, text] of poseParts(pose)) out.set(`${kind}:${name}.${key}`, text);
839
+ };
840
+ for (const bone of skeleton.bones) take('bone', bone.data.name, bone.appliedPose);
841
+ for (const slot of skeleton.slots) take('slot', slot.data.name, slot.appliedPose);
842
+ for (const constraint of skeleton.constraints) take('constraint', constraint.data.name, constraint.appliedPose as object);
843
+ out.set('drawOrder', skeleton.drawOrder.appliedPose.map((slot) => slot.data.name).join('>'));
844
+ return out;
845
+ };
846
+ const cells: TimelineAddCell[] = [];
847
+ for (const skin of [null, ...data.skins]) {
848
+ const skeleton = new Skeleton(data);
849
+ if (skin !== null) skeleton.setSkin(skin);
850
+ probeStartStates(skeleton).forEach((displace, at) => {
851
+ const cell: TimelineAddCell = { view: skin?.name ?? '(none)', state: at === 0 ? 'setup' : 'displaced', letter: '-', changed: [] };
852
+ for (const time of probeTimes(timeline)) {
853
+ skeleton.setupPose();
854
+ for (const bone of skeleton.bones) bone.resetConstrained();
855
+ for (const slot of skeleton.slots) slot.resetConstrained();
856
+ for (const constraint of skeleton.constraints) constraint.resetConstrained();
857
+ skeleton.drawOrder.resetConstrained();
858
+ displace();
859
+ const before = fields(skeleton);
860
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
861
+ const once = fields(skeleton);
862
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
863
+ const twice = fields(skeleton);
864
+ let wrote = false;
865
+ let again = false;
866
+ for (const [field, b] of before) {
867
+ const o = once.get(field) ?? '';
868
+ const w = twice.get(field) ?? '';
869
+ if (o !== b) wrote = true;
870
+ if (w !== o) again = true;
871
+ if (o !== b || w !== o) cell.changed.push({ field, t: time, before: b, once: o, twice: w });
872
+ }
873
+ if (again) cell.letter = 'A';
874
+ else if (wrote && cell.letter === '-') cell.letter = 'W';
875
+ }
876
+ cells.push(cell);
877
+ });
878
+ }
879
+ return cells;
880
+ }
881
+
851
882
  /**
852
883
  * Where two texts first differ, as A18 names it: the 1-based line and each
853
884
  * side's line, trimmed and cut to 120 characters.
@@ -898,6 +929,9 @@ export function runtimeFacts(skeletonText: string, atlasText: string): {
898
929
  regionJoins: RegionJoinFacts;
899
930
  atlasRegions: AtlasRegionFacts;
900
931
  stage: StageFacts;
932
+ skeletonRoster: SkeletonRosterFacts;
933
+ boneTimelines: BoneTimelineFacts;
934
+ eventKeys: EventKeyFacts;
901
935
  } | null {
902
936
  let atlas: TextureAtlas;
903
937
  let data: ReturnType<SkeletonJson['readSkeletonData']>;
@@ -918,9 +952,140 @@ export function runtimeFacts(skeletonText: string, atlasText: string): {
918
952
  regionJoins: spineRegionJoins(atlasText, raw),
919
953
  atlasRegions: { atlas },
920
954
  stage: spineStage(data),
955
+ skeletonRoster: rawSkeletonRoster(raw),
956
+ boneTimelines: rawBoneTimelines(raw),
957
+ eventKeys: rawEventKeys(raw),
921
958
  };
922
959
  }
923
960
 
961
+ /**
962
+ * The runtime's supply of `SkeletonRosterFacts` (issue #1025, cut 4c-4): the
963
+ * skeleton JSON's `bones` and `slots`, read as A12, A25 and A26 always read
964
+ * them — a slot is any object in the array, named `String(name)`, dark where
965
+ * the key is present; a bone is an object with a string name, its parent the
966
+ * string the file states or `null`. Off the raw JSON because those three run
967
+ * whatever the round trip did. The model side's supply is
968
+ * `./assertions/model/skeleton_roster.ts`.
969
+ */
970
+ export function rawSkeletonRoster(raw: Json | null): SkeletonRosterFacts {
971
+ const bones: RosterBone[] = [];
972
+ for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
973
+ if (isObj(bone) && typeof bone.name === 'string') bones.push({ name: bone.name, parent: typeof bone.parent === 'string' ? bone.parent : null });
974
+ }
975
+ const slots: RosterSlot[] = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []).filter(isObj).map((s) => ({ name: String(s.name), dark: 'dark' in s }));
976
+ return { bones, slots };
977
+ }
978
+
979
+ /**
980
+ * The slot timelines of the skeleton JSON in the file's order — every
981
+ * animation that is an object, every slot entry that is an object — the walk
982
+ * `walkTimelines` made for A12 (issue #1025, cut 4c-4), and the same walk
983
+ * A45's supply makes (`spineSlotColourFacts`), here without the loaded
984
+ * skeleton because A12 runs whatever the round trip did. The model side
985
+ * supplies the same list (`fileSlotTimelines`).
986
+ */
987
+ export function rawSlotTimelines(raw: Json | null): SlotTimelines[] {
988
+ const out: SlotTimelines[] = [];
989
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
990
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
991
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
992
+ for (const [slot, timelines] of Object.entries(anim.slots)) {
993
+ if (!isObj(timelines)) continue;
994
+ out.push({ animation, slot, timelines });
995
+ }
996
+ }
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * The runtime's supply of `BoneTimelineFacts` (issue #1025, cut 4c-4): every
1002
+ * (animation, bone) pair of the skeleton JSON, in the file's order — every
1003
+ * animation that is an object with a `bones` object, every bone entry,
1004
+ * whatever it holds — the walk A24, A29 and A30 made over the raw JSON. The
1005
+ * model side's supply is `./assertions/model/bone_timelines.ts`.
1006
+ */
1007
+ export function rawBoneTimelines(raw: Json | null): BoneTimelineFacts {
1008
+ const boneTimelines: BoneTimelines[] = [];
1009
+ const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
1010
+ for (const [animation, anim] of Object.entries(anims)) {
1011
+ if (!isObj(anim) || !isObj(anim.bones)) continue;
1012
+ for (const [bone, timelines] of Object.entries(anim.bones as Json)) boneTimelines.push({ animation, bone, timelines });
1013
+ }
1014
+ return { boneTimelines };
1015
+ }
1016
+
1017
+ /**
1018
+ * The runtime's supply of `EventKeyFacts` (issue #1025, cut 4c-4) — and the
1019
+ * four clauses of A32 that stay with the round trip.
1020
+ *
1021
+ * Every event key of the skeleton JSON, in the file's order, with what the
1022
+ * body's one moved clause reads (the fields the key states, whether its event
1023
+ * declares an audio path). The four clauses below run here, over the raw JSON,
1024
+ * exactly as they ran in A32's body, and hand their findings in as `kept`, in
1025
+ * the order they printed, with `stopped` where the clause ended the key's
1026
+ * reading (the body prints them at the same place). They stay because each is
1027
+ * a state the model document's reader refuses by name (`readEventKeys` in
1028
+ * `src/core/events.ts`): a key with no string name, an event the skeleton
1029
+ * does not declare, a time that is not a finite number, a time before the key
1030
+ * before it — measured on forged documents, each refused at the key's
1031
+ * address, so no readable document reaches them and the model side has no
1032
+ * input to print them from.
1033
+ */
1034
+ export function rawEventKeys(raw: Json | null): EventKeyFacts {
1035
+ if (!raw) return { parsed: false, animations: false, timelines: 0, keys: [] };
1036
+ if (!isObj(raw.animations)) return { parsed: true, animations: false, timelines: 0, keys: [] };
1037
+ const declared = isObj(raw.events) ? (raw.events as Json) : {};
1038
+ const known = Object.keys(declared);
1039
+ const keys: EventKeyEntry[] = [];
1040
+ let timelines = 0;
1041
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
1042
+ if (!isObj(anim) || !Array.isArray(anim.events)) continue;
1043
+ timelines++;
1044
+ let previous = -Infinity;
1045
+ (anim.events as unknown[]).forEach((key, k) => {
1046
+ const at = eventKeyAt(animName, k);
1047
+ const kept: string[] = [];
1048
+ const stop = (name: string): void => void keys.push({ animation: animName, index: k, kept, stopped: true, name, sets: { volume: false, balance: false }, audio: false });
1049
+ if (!isObj(key) || typeof key.name !== 'string') {
1050
+ kept.push(`${at}: an event key needs a string "name"`);
1051
+ return stop('');
1052
+ }
1053
+ const definition = declared[key.name];
1054
+ if (definition === undefined) {
1055
+ kept.push(
1056
+ `${at}: fires "${key.name}", which the skeleton's events block does not declare` +
1057
+ (known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
1058
+ );
1059
+ return stop(key.name);
1060
+ }
1061
+ // `time` defaults to 0 when absent (`:1247`), which is what the editor
1062
+ // writes for a firing on frame 0.
1063
+ const time = key.time === undefined ? 0 : key.time;
1064
+ if (typeof time !== 'number' || !Number.isFinite(time)) {
1065
+ kept.push(`${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
1066
+ return stop(key.name);
1067
+ }
1068
+ if (time < previous) {
1069
+ kept.push(
1070
+ `${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
1071
+ 'array order and never sorts them, so the earlier firing is unreachable',
1072
+ );
1073
+ }
1074
+ previous = Math.max(previous, time);
1075
+ keys.push({
1076
+ animation: animName,
1077
+ index: k,
1078
+ kept,
1079
+ stopped: false,
1080
+ name: key.name,
1081
+ sets: { volume: key.volume !== undefined, balance: key.balance !== undefined },
1082
+ audio: isObj(definition) && typeof definition.audio === 'string',
1083
+ });
1084
+ });
1085
+ }
1086
+ return { parsed: true, animations: true, timelines, keys };
1087
+ }
1088
+
924
1089
  /**
925
1090
  * The runtime's supply of A45's facts (issue #1025): the slot timelines read
926
1091
  * off the skeleton JSON in the file's order — every animation the file keys,
@@ -959,6 +1124,156 @@ function spineSlotColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['r
959
1124
  };
960
1125
  }
961
1126
 
1127
+ /**
1128
+ * The runtime's supply of A39's fact (issue #1025, cut 4c-3): the survey of
1129
+ * the loaded skeleton, read and posed by spine-core (`surveyDeformKeys`) — the
1130
+ * call A39 always made. The model side's supply is
1131
+ * `./assertions/model/deform_survey.ts`.
1132
+ */
1133
+ export function spineDeformSurvey(data: ReturnType<SkeletonJson['readSkeletonData']>): DeformSurveyFacts {
1134
+ return { survey: (exempt) => surveyDeformKeys(data, exempt) };
1135
+ }
1136
+
1137
+ /**
1138
+ * The runtime's supply of A09's facts (issue #1025, cut 4c-3): the loaded
1139
+ * animations in the loaded order — the file's — each with its duration and
1140
+ * every loaded timeline's `getDuration()`, as A09 always read them. The model
1141
+ * side's supply is `./assertions/model/animation_durations.ts`.
1142
+ */
1143
+ export function spineAnimationDurations(data: ReturnType<SkeletonJson['readSkeletonData']>): AnimationDurationFacts {
1144
+ return { animations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration, timelineDurations: anim.timelines.map((t) => t.getDuration()) })) };
1145
+ }
1146
+
1147
+ /**
1148
+ * The runtime's supply of A43's facts (issue #1025, cut 4c-3): the stated dark
1149
+ * colours read off the skeleton JSON in the file's slot order, each slot named
1150
+ * as A43's walk always named it; the setup dark colour the loaded slot holds;
1151
+ * A45's walk of the file's slot timelines; whether an animation loaded; and a
1152
+ * slot's two colours posed on a fresh, non-looping track by spine-core — setup
1153
+ * pose, `update(0)`, `updateWorldTransform(Physics.reset)`, the track stepped
1154
+ * to the time and applied, then `update` and `updateWorldTransform
1155
+ * (Physics.update)` by the same time, A43's recipe unchanged. The model side's
1156
+ * supply is `./assertions/model/two_colour.ts`.
1157
+ */
1158
+ export function spineTwoColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): TwoColourFacts {
1159
+ const slotDarks: Array<{ slot: string; dark: string }> = [];
1160
+ for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
1161
+ if (isObj(slot) && typeof slot.dark === 'string') slotDarks.push({ slot: String(slot.name), dark: slot.dark });
1162
+ }
1163
+ return {
1164
+ slotDarks,
1165
+ loadedDark: (name) => data.findSlot(name)?.setupPose.darkColor ?? null,
1166
+ slotTimelines: spineSlotColourFacts(raw, data).slotTimelines,
1167
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1168
+ posedTint: (animName, slotName, time) => {
1169
+ const skeleton = new Skeleton(data);
1170
+ const state = new AnimationState(new AnimationStateData(data));
1171
+ state.setAnimation(0, animName, false);
1172
+ skeleton.setupPose();
1173
+ skeleton.update(0);
1174
+ skeleton.updateWorldTransform(Physics.reset);
1175
+ state.update(time);
1176
+ state.apply(skeleton);
1177
+ skeleton.update(time);
1178
+ skeleton.updateWorldTransform(Physics.update);
1179
+ const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
1180
+ if (posed === undefined) return undefined;
1181
+ return { light: posed.color, dark: posed.darkColor ?? null };
1182
+ },
1183
+ };
1184
+ }
1185
+
1186
+ /**
1187
+ * The runtime's supply of A46's facts (issue #1025, cut 4c-3): the skin
1188
+ * entries and sequence timelines read off the skeleton JSON as A46's walks
1189
+ * always read them — the skins, a skin's slot keys and a slot's placeholders in
1190
+ * the file's order, an entry with no `type` a region, an entry of another kind
1191
+ * left out, a timeline whose `sequence` is not a list left out — each entry
1192
+ * marked loaded where the slot exists and the loaded skin of that name holds an
1193
+ * attachment at it; and what a slot shows on a fresh, non-looping track posed
1194
+ * by spine-core — setup pose, `update(0)`, `updateWorldTransform
1195
+ * (Physics.reset)`, the track stepped to the time and applied, A46's recipe
1196
+ * unchanged. The loaded attachment is answered by its address: every loaded
1197
+ * skin's `getAttachments()` gives each attachment object the skin, slot and
1198
+ * placeholder it is filed under, and the object a linked mesh plays its
1199
+ * timelines as (`timelineAttachment`) is answered the same way. The model
1200
+ * side's supply is `./assertions/model/sequences.ts`.
1201
+ */
1202
+ export function spineSequenceFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SequenceFacts {
1203
+ const entries: SequenceSkinEntry[] = [];
1204
+ for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
1205
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
1206
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
1207
+ const loadedSkin = data.findSkin(skinName);
1208
+ for (const [slotName, perSlot] of Object.entries(skin.attachments)) {
1209
+ if (!isObj(perSlot)) continue;
1210
+ const slotIndex = data.findSlot(slotName)?.index;
1211
+ for (const [placeholder, entry] of Object.entries(perSlot)) {
1212
+ if (!isObj(entry)) continue;
1213
+ const type = entry.type === undefined ? 'region' : entry.type;
1214
+ if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
1215
+ const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
1216
+ entries.push({ skin: skinName, slot: slotName, placeholder, name: entry.name, path: entry.path, sequence: entry.sequence, loaded: loaded !== null });
1217
+ }
1218
+ }
1219
+ }
1220
+ const timelines: SequenceTimeline[] = [];
1221
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
1222
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
1223
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
1224
+ for (const [skin, perSkin] of Object.entries(anim.attachments)) {
1225
+ if (!isObj(perSkin)) continue;
1226
+ for (const [slot, perSlot] of Object.entries(perSkin)) {
1227
+ if (!isObj(perSlot)) continue;
1228
+ for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
1229
+ if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
1230
+ timelines.push({ animation, skin, slot, placeholder, keys: perAttachment.sequence as unknown[] });
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+ /** Every loaded attachment object -> the address it is filed under, first filing kept. */
1236
+ let addressOf: Map<object, EntryAddress> | null = null;
1237
+ const address = (attachment: object): EntryAddress => {
1238
+ if (addressOf === null) {
1239
+ addressOf = new Map();
1240
+ for (const skin of data.skins) {
1241
+ for (const entry of skin.getAttachments()) {
1242
+ if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
1243
+ }
1244
+ }
1245
+ }
1246
+ // An attachment no skin files has no address: it can match no timeline's.
1247
+ return addressOf.get(attachment) ?? '';
1248
+ };
1249
+ return {
1250
+ entries,
1251
+ timelines,
1252
+ hasSlot: (slot) => data.findSlot(slot) !== null,
1253
+ duration: (animation) => data.findAnimation(animation)?.duration ?? null,
1254
+ posedFrame: (animName, slotName, time) => {
1255
+ const slotIndex = data.findSlot(slotName)?.index;
1256
+ if (slotIndex === undefined) return null;
1257
+ const skeleton = new Skeleton(data);
1258
+ const state = new AnimationState(new AnimationStateData(data));
1259
+ state.setAnimation(0, animName, false);
1260
+ skeleton.setupPose();
1261
+ skeleton.update(0);
1262
+ skeleton.updateWorldTransform(Physics.reset);
1263
+ state.update(time);
1264
+ state.apply(skeleton);
1265
+ const pose = skeleton.slots[slotIndex].appliedPose;
1266
+ const shown = pose.attachment;
1267
+ if (shown === null) return null;
1268
+ // The object it plays timelines as — `applyToSlot`'s second test — where the attachment carries one.
1269
+ const playsAs = shown.timelineAttachment ?? shown;
1270
+ const sequence = shown instanceof RegionAttachment || shown instanceof MeshAttachment ? shown.sequence : null;
1271
+ const region = sequence === null ? null : ((sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null);
1272
+ return { shown: address(shown), playsAs: address(playsAs), region };
1273
+ },
1274
+ };
1275
+ }
1276
+
962
1277
  /**
963
1278
  * The runtime's supply of `SkinMeshFacts` (issue #1025, cut 4c-1): every
964
1279
  * loaded `MeshAttachment` of every skin, linked meshes included, in the loaded
@@ -1398,6 +1713,129 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
1398
1713
  return { animations: data.animations.length, constraints, timelines, pathSlots };
1399
1714
  }
1400
1715
 
1716
+ /**
1717
+ * The runtime's supply of `ConstraintTargetFacts` (issue #1025, cut 4c-5):
1718
+ * A34's walk of the raw JSON, as A34 always walked it — the `constraints`
1719
+ * array's objects, then each animation (`Object.entries`, the file's order)
1720
+ * that is an object, its `ik` and `transform` groups (each entry ONE key
1721
+ * array) and its `path`, `physics` and `slider` groups (each entry an object
1722
+ * of named key arrays, or a bare value), each where the group is an object.
1723
+ * `reach` builds the timeline the parser builds for a physics timeline NAME
1724
+ * (`unnamedPhysicsTimeline`) and asks it of the file's physics constraint
1725
+ * objects (`unnamedPhysicsReach`). The model side's supply is
1726
+ * `./assertions/model/constraint_targets.ts`.
1727
+ */
1728
+ export function rawConstraintTargets(raw: Json): ConstraintTargetFacts {
1729
+ const objects = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter((entry): entry is Json => isObj(entry));
1730
+ const constraints = objects.map((entry) => ({ name: typeof entry.name === 'string' ? entry.name : null, type: String(entry.type), spelled: String(entry.name) }));
1731
+ const rawPhysics = objects.filter((entry) => entry.type === 'physics');
1732
+ const keyArray = (timeline: string, keys: unknown): TargetKeyArray => ({ timeline, keys: Array.isArray(keys) ? { count: keys.length } : { spelled: `${JSON.stringify(keys)}` } });
1733
+ let animations: TargetAnimation[] | null = null;
1734
+ if (isObj(raw.animations)) {
1735
+ animations = [];
1736
+ for (const [name, anim] of Object.entries(raw.animations)) {
1737
+ if (!isObj(anim)) continue;
1738
+ const groups: TargetAnimation['groups'][number][] = [];
1739
+ for (const group of ['ik', 'transform'] as const) {
1740
+ if (!isObj(anim[group])) continue;
1741
+ groups.push({ group, entries: Object.entries(anim[group] as Json).map(([target, keys]) => ({ name: target, bare: null, keyArrays: [keyArray('', keys)] })) });
1742
+ }
1743
+ for (const group of NAMED_TIMELINE_GROUPS) {
1744
+ if (!isObj(anim[group])) continue;
1745
+ groups.push({
1746
+ group,
1747
+ entries: Object.entries(anim[group] as Json).map(([target, timelines]) =>
1748
+ isObj(timelines) ? { name: target, bare: null, keyArrays: Object.entries(timelines).map(([timeline, keys]) => keyArray(timeline, keys)) } : { name: target, bare: JSON.stringify(timelines), keyArrays: [] },
1749
+ ),
1750
+ });
1751
+ }
1752
+ animations.push({ name, groups });
1753
+ }
1754
+ }
1755
+ return {
1756
+ constraints,
1757
+ groupsByAnimation: animations,
1758
+ reach: (name) => {
1759
+ const timeline = unnamedPhysicsTimeline(name);
1760
+ if (timeline === null) return null;
1761
+ return { resets: timeline instanceof PhysicsConstraintResetTimeline, reached: unnamedPhysicsReach(timeline, rawPhysics).map((one) => String(one.name)) };
1762
+ },
1763
+ };
1764
+ }
1765
+
1766
+ /**
1767
+ * The runtime's supply of `SliderCompositionFacts` (issue #1025, cut 4c-5):
1768
+ * the loaded sliders in the `constraints` array's order, and each animation's
1769
+ * loaded timelines in the order the runtime built them, each with its class
1770
+ * name and its `propertyIds` — spelled as the facts spell an id (the property's
1771
+ * name off `Property`, the index after it as loaded, and a deform or sequence
1772
+ * timeline's attachment OBJECT replaced by the address a skin files it under,
1773
+ * first filing kept, as `spineSequenceFacts` maps it) — and the sentence's
1774
+ * words for each, `describe` as A40 always wrote it. `behaviour` is today's
1775
+ * probe, `timelineAddBehaviour`. The model side's supply is
1776
+ * `./assertions/model/slider_composition.ts`.
1777
+ */
1778
+ export function spineSliderComposition(data: ReturnType<SkeletonJson['readSkeletonData']>): SliderCompositionFacts {
1779
+ let addressOf: Map<object, string> | null = null;
1780
+ const address = (attachment: object): string => {
1781
+ if (addressOf === null) {
1782
+ addressOf = new Map();
1783
+ for (const skin of data.skins) {
1784
+ for (const entry of skin.getAttachments()) {
1785
+ if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
1786
+ }
1787
+ }
1788
+ }
1789
+ return addressOf.get(attachment) ?? '';
1790
+ };
1791
+ /** What a property id points at, in the words the rig spec uses — A40's `describe`. */
1792
+ const describe = (timeline: Timeline, id: string): string => {
1793
+ const property = Property[Number(id.split('|')[0])] ?? id;
1794
+ if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
1795
+ if (timeline instanceof DeformTimeline) {
1796
+ return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
1797
+ }
1798
+ if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
1799
+ if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
1800
+ return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
1801
+ }
1802
+ return `the skeleton's ${property}`;
1803
+ };
1804
+ /** An id as the facts spell it: the property's name in place of its number, an attachment's address in place of its serial. */
1805
+ const idOf = (timeline: Timeline, id: string): string => {
1806
+ const [head, ...rest] = id.split('|');
1807
+ const named = [Property[Number(head)] ?? head, ...rest];
1808
+ if (timeline instanceof DeformTimeline || timeline instanceof RuntimeSequenceTimeline) named[2] = address(timeline.attachment);
1809
+ return named.join('|');
1810
+ };
1811
+ const timelinesOf = (animation: Animation): SliderTimelineFact[] =>
1812
+ animation.timelines.map((timeline) => ({
1813
+ runtimeClass: timeline.constructor.name,
1814
+ properties: timeline.propertyIds.map((id) => ({ id: idOf(timeline, id), property: Property[Number(id.split('|')[0])] ?? id, names: describe(timeline, id) })),
1815
+ }));
1816
+ const sliders: SliderFact[] = [];
1817
+ data.constraints.forEach((c, index) => {
1818
+ if (!(c instanceof SliderData)) return;
1819
+ sliders.push({
1820
+ name: c.name,
1821
+ index,
1822
+ mix: c.setupPose.mix,
1823
+ additive: c.additive,
1824
+ skinRequired: c.skinRequired,
1825
+ skins: data.skins.filter((skin) => skin.constraints.includes(c)).map((skin) => skin.name),
1826
+ animation: c.animation === null ? null : { name: c.animation.name, timelines: timelinesOf(c.animation) },
1827
+ });
1828
+ });
1829
+ return {
1830
+ sliders,
1831
+ behaviour: (animationName, at) => {
1832
+ const timeline = data.findAnimation(animationName)?.timelines[at];
1833
+ if (timeline === undefined) throw new Error(`internal: animation "${animationName}" has no timeline ${at}`);
1834
+ return timelineAddBehaviour(data, timeline);
1835
+ },
1836
+ };
1837
+ }
1838
+
1401
1839
  /**
1402
1840
  * The facts cut 4c-2's bodies read, as `validate()` supplies them from a pair
1403
1841
  * spine-core loads — or `null` when the load throws (A00's failure). For the
@@ -1416,6 +1854,135 @@ export function runtimeRigFacts(skeletonText: string, atlasText: string): { mesh
1416
1854
  return { meshes: spineMeshFacts(data, raw), polygons: spinePolygonFacts(data, raw), links: spineLinkFacts(data, raw), constraints: spineConstraintFacts(data) };
1417
1855
  }
1418
1856
 
1857
+ /**
1858
+ * Cut 4c-3's facts (issue #1025) as `validate()` supplies them from a pair
1859
+ * spine-core loads — A39's survey, A09's durations, A43's tint and A46's series
1860
+ * — or `null` when the load throws. For the selftest's `VF12` and
1861
+ * `tools/verdict_gate.ts`, which hand them to the bodies and ask the model
1862
+ * side's suppliers the same questions, value by value.
1863
+ */
1864
+ /**
1865
+ * The runtime's supply of A10's facts (issue #1025, cut 4c-5a): every pose A10
1866
+ * reads, posed by spine-core exactly as A10 always posed it.
1867
+ *
1868
+ * - The setup pose: `setupPose()`, `update(0)`,
1869
+ * `updateWorldTransform(Physics.reset)`, no animation set.
1870
+ * - Each animation's walk: `setAnimation(0, name, true)` — a LOOPING track —
1871
+ * over a fresh skeleton posed as above, then per step `AnimationState.update
1872
+ * (step)`, `apply`, `Skeleton.update(step)`, `updateWorldTransform
1873
+ * (Physics.update)`, one pose read after each.
1874
+ * - A bone's mode at a key: a fresh, non-looping track, the setup pose reset,
1875
+ * the track stepped to the key's time and applied, then `update` and
1876
+ * `updateWorldTransform(Physics.update)` by the same time.
1877
+ *
1878
+ * A pose is read as `posedNumbersOf` reads it for `render` (every bone's world
1879
+ * transform, every shown region's and mesh's world vertices), with each bone's
1880
+ * `appliedPose.inherit` — `null` where it is a value `updateWorldTransform`'s
1881
+ * switch has a case for, read off the runtime's own enum — and every slot's
1882
+ * light and dark colour. The model side's supply is
1883
+ * `./assertions/model/stepped_poses.ts`.
1884
+ */
1885
+ export function spineSteppedPoses(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SteppedPoseFacts {
1886
+ /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
1887
+ const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
1888
+ const frameOf = (skeleton: Skeleton): SteppedFrame => {
1889
+ const { bones, drawn } = posedNumbersOf(skeleton);
1890
+ return {
1891
+ bones: bones.map((b, i) => {
1892
+ const inherit = skeleton.bones[i].appliedPose.inherit;
1893
+ return { ...b, inherit: isMode(inherit) ? null : String(inherit) };
1894
+ }),
1895
+ drawn,
1896
+ slots: skeleton.slots.map((slot) => {
1897
+ const c = slot.appliedPose.color;
1898
+ const d = slot.appliedPose.darkColor;
1899
+ return { name: slot.data.name, colour: [c.r, c.g, c.b, c.a] as const, dark: d === null ? null : ([d.r, d.g, d.b] as const) };
1900
+ }),
1901
+ };
1902
+ };
1903
+ return {
1904
+ boneCount: data.bones.length,
1905
+ steppedAnimations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration })),
1906
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1907
+ hasBone: (name) => Boolean(data.findBone(name)),
1908
+ posedInherit: (animName, boneName, time) => {
1909
+ const skeleton = new Skeleton(data);
1910
+ const state = new AnimationState(new AnimationStateData(data));
1911
+ state.setAnimation(0, animName, false);
1912
+ skeleton.setupPose();
1913
+ skeleton.update(0);
1914
+ skeleton.updateWorldTransform(Physics.reset);
1915
+ state.update(time);
1916
+ state.apply(skeleton);
1917
+ skeleton.update(time);
1918
+ skeleton.updateWorldTransform(Physics.update);
1919
+ const bone = skeleton.findBone(boneName);
1920
+ if (bone === null) return undefined;
1921
+ const posed = bone.appliedPose.inherit;
1922
+ return isMode(posed) ? null : String(posed);
1923
+ },
1924
+ statedInherit: (name) => {
1925
+ const rawBone = Array.isArray(raw?.bones) ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === name) : undefined;
1926
+ return `${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}`;
1927
+ },
1928
+ setup: () => {
1929
+ const atRest = new Skeleton(data);
1930
+ atRest.setupPose();
1931
+ atRest.update(0);
1932
+ atRest.updateWorldTransform(Physics.reset);
1933
+ return frameOf(atRest);
1934
+ },
1935
+ walk: (animName, step, frames) => {
1936
+ const skeleton = new Skeleton(data);
1937
+ const state = new AnimationState(new AnimationStateData(data));
1938
+ state.setAnimation(0, animName, true);
1939
+ skeleton.setupPose();
1940
+ skeleton.update(0);
1941
+ skeleton.updateWorldTransform(Physics.reset);
1942
+ const out: SteppedFrame[] = [];
1943
+ for (let i = 0; i < frames; i++) {
1944
+ state.update(step);
1945
+ state.apply(skeleton);
1946
+ skeleton.update(step);
1947
+ skeleton.updateWorldTransform(Physics.update);
1948
+ out.push(frameOf(skeleton));
1949
+ }
1950
+ return out;
1951
+ },
1952
+ };
1953
+ }
1954
+
1955
+ export function runtimePosedFacts(skeletonText: string, atlasText: string): { deformSurvey: DeformSurveyFacts; animationDurations: AnimationDurationFacts; twoColour: TwoColourFacts; sequences: SequenceFacts; steppedPoses: SteppedPoseFacts; boneTimelines: BoneTimelineFacts } | null {
1956
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1957
+ let raw: Json;
1958
+ try {
1959
+ raw = JSON.parse(skeletonText) as Json;
1960
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
1961
+ } catch {
1962
+ return null;
1963
+ }
1964
+ return { deformSurvey: spineDeformSurvey(data), animationDurations: spineAnimationDurations(data), twoColour: spineTwoColourFacts(raw, data), sequences: spineSequenceFacts(raw, data), steppedPoses: spineSteppedPoses(raw, data), boneTimelines: rawBoneTimelines(raw) };
1965
+ }
1966
+
1967
+ /**
1968
+ * Cut 4c-5's facts (issue #1025) as `validate()` supplies them from a pair
1969
+ * spine-core loads — A40's sliders and A34's constraint groups, with the
1970
+ * constraint facts A40 reads beside them — or `null` when the load throws.
1971
+ * For the selftest's `VF14` and `tools/verdict_gate.ts`, which hand them to
1972
+ * the bodies and ask the model side's suppliers the same questions.
1973
+ */
1974
+ export function runtimeCut4c5Facts(skeletonText: string, atlasText: string): { sliderComposition: SliderCompositionFacts; constraintTargets: ConstraintTargetFacts; constraints: ConstraintFacts } | null {
1975
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1976
+ let raw: Json;
1977
+ try {
1978
+ raw = JSON.parse(skeletonText) as Json;
1979
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
1980
+ } catch {
1981
+ return null;
1982
+ }
1983
+ return { sliderComposition: spineSliderComposition(data), constraintTargets: rawConstraintTargets(raw), constraints: spineConstraintFacts(data) };
1984
+ }
1985
+
1419
1986
  /** A fact supply computed on first use and kept: a supplier that throws throws inside the `check` that asked, as the body it feeds always did. */
1420
1987
  function once<T>(supply: () => T): () => T {
1421
1988
  let held: { value: T } | null = null;
@@ -1686,60 +2253,14 @@ export function validate(input: ValidateInput): ValidateReport {
1686
2253
  // It runs on the raw JSON rather than on the loaded data because the loaded
1687
2254
  // `Event` no longer remembers which fields the file wrote: an override that was
1688
2255
  // dropped and an override that matched the default are the same object.
1689
- check('A32_EVENT_KEYS_RESOLVE', () => {
1690
- if (!raw) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
1691
- if (!isObj(raw.animations)) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton declares no animations');
1692
- const declared = isObj(raw.events) ? (raw.events as Json) : {};
1693
- const known = Object.keys(declared);
1694
- let sawATimeline = false;
1695
- for (const [animName, anim] of Object.entries(raw.animations as Json)) {
1696
- if (!isObj(anim) || !Array.isArray(anim.events)) continue;
1697
- sawATimeline = true;
1698
- let previous = -Infinity;
1699
- (anim.events as unknown[]).forEach((key, k) => {
1700
- const at = `animation "${animName}" event key ${k}`;
1701
- if (!isObj(key) || typeof key.name !== 'string') {
1702
- fail('A32_EVENT_KEYS_RESOLVE', `${at}: an event key needs a string "name"`);
1703
- return;
1704
- }
1705
- const definition = declared[key.name];
1706
- if (definition === undefined) {
1707
- fail(
1708
- 'A32_EVENT_KEYS_RESOLVE',
1709
- `${at}: fires "${key.name}", which the skeleton's events block does not declare` +
1710
- (known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
1711
- );
1712
- return;
1713
- }
1714
- // `time` defaults to 0 when absent (`:1247`), which is what the editor
1715
- // writes for a firing on frame 0.
1716
- const time = key.time === undefined ? 0 : key.time;
1717
- if (typeof time !== 'number' || !Number.isFinite(time)) {
1718
- fail('A32_EVENT_KEYS_RESOLVE', `${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
1719
- return;
1720
- }
1721
- if (time < previous) {
1722
- fail(
1723
- 'A32_EVENT_KEYS_RESOLVE',
1724
- `${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
1725
- 'array order and never sorts them, so the earlier firing is unreachable',
1726
- );
1727
- }
1728
- previous = Math.max(previous, time);
1729
- const hasAudio = isObj(definition) && typeof definition.audio === 'string';
1730
- for (const field of ['volume', 'balance'] as const) {
1731
- if (key[field] !== undefined && !hasAudio) {
1732
- fail(
1733
- 'A32_EVENT_KEYS_RESOLVE',
1734
- `${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
1735
- `${field} only for an event that has one, so it is dropped in silence`,
1736
- );
1737
- }
1738
- }
1739
- });
1740
- }
1741
- if (!sawATimeline) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
1742
- });
2256
+ //
2257
+ // ✂️ Split per clause since issue #1025 (cut 4c-4): the third mode is the
2258
+ // rig's — a readable model document states a key's `volume` and the event's
2259
+ // `audio` — and its clause is the body's (`./assertions/bodies/a32.ts`); the
2260
+ // first two, with "no string name" and "a time that is not a finite number",
2261
+ // are states the document's reader refuses by name, so they stay here, in
2262
+ // `rawEventKeys`, and the body prints what they found at their place.
2263
+ check('A32_EVENT_KEYS_RESOLVE', () => a32EventKeysResolve(verdicts, rawEventKeys(raw)));
1743
2264
 
1744
2265
  // --- A34: a constraint timeline aims at a constraint of that type ---------
1745
2266
  //
@@ -1772,129 +2293,15 @@ export function validate(input: ValidateInput): ValidateReport {
1772
2293
  // constant now, and the message that names the timelines a group takes reads
1773
2294
  // them off `CHANNELS_BY_KIND` — it used to be a ternary over two groups,
1774
2295
  // which is a sentence that cannot be extended without being rewritten.
1775
- check('A34_CONSTRAINT_TIMELINE_TARGETS', () => {
1776
- if (!raw) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)');
1777
- if (!isObj(raw.animations)) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton declares no animations');
1778
- // name -> the KINDS declared under it, because that is the namespace the
1779
- // lookup this assertion is about resolves in: `findConstraint(name, type)`
1780
- // tests the type first, so a skeleton may carry `leg` as an ik constraint
1781
- // AND as a transform one and each group finds its own (issue #692). A map
1782
- // keyed by the name alone let the second of a pair overwrite the first, and
1783
- // this assertion then reported a correct file as a type mismatch.
1784
- const kindsOf = new Map<string, string[]>();
1785
- for (const entry of Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []) {
1786
- if (isObj(entry) && typeof entry.name === 'string') {
1787
- kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), String(entry.type)]);
1788
- }
1789
- }
1790
- /** The file's physics constraints, which the unnamed physics group is read against. */
1791
- const rawPhysics = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter(
1792
- (entry): entry is Json => isObj(entry) && entry.type === 'physics',
1793
- );
1794
- let sawATimeline = false;
1795
- /** One target of one group: the name resolves, the type matches, keys exist. */
1796
- const checkTarget = (at: string, group: string, name: string, keyArrays: Array<[string, unknown]>): void => {
1797
- sawATimeline = true;
1798
- const declared = kindsOf.get(name) ?? [];
1799
- if (declared.length === 0) {
1800
- const known = [...kindsOf.entries()].filter(([, kinds]) => kinds.includes(group)).map(([n]) => n);
1801
- fail(
1802
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1803
- `${at}: the skeleton's constraints array has no "${name}"` +
1804
- (known.length ? ` (${group} constraints: ${known.join(', ')})` : `, and no ${group} constraint at all`),
1805
- );
1806
- return;
1807
- }
1808
- if (!declared.includes(group)) {
1809
- fail(
1810
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1811
- `${at}: "${name}" is declared as a "${declared.join('"/"')}" constraint, so the ${group} lookup misses it and the loader throws`,
1812
- );
1813
- return;
1814
- }
1815
- checkKeys(at, keyArrays);
1816
- };
1817
- /** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
1818
- const checkKeys = (at: string, keyArrays: Array<[string, unknown]>): void => {
1819
- if (keyArrays.length === 0) {
1820
- fail(
1821
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1822
- `${at}: the constraint is named and carries no timeline at all; the group is walked and nothing happens`,
1823
- );
1824
- return;
1825
- }
1826
- for (const [timeline, keys] of keyArrays) {
1827
- if (Array.isArray(keys) && keys.length > 0) continue;
1828
- fail(
1829
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1830
- `${at}${timeline ? ` timeline "${timeline}"` : ''}: the key array is ` +
1831
- `${Array.isArray(keys) ? 'empty' : JSON.stringify(keys)}; the parser reads key 0, finds nothing and ` +
1832
- 'skips the whole timeline without a word',
1833
- );
1834
- }
1835
- };
1836
- for (const [animName, anim] of Object.entries(raw.animations as Json)) {
1837
- if (!isObj(anim)) continue;
1838
- // group.<constraint> = keys[]
1839
- for (const group of ['ik', 'transform'] as const) {
1840
- if (!isObj(anim[group])) continue;
1841
- for (const [name, keys] of Object.entries(anim[group] as Json)) {
1842
- checkTarget(`animation "${animName}" ${group} timeline "${name}"`, group, name, [['', keys]]);
1843
- }
1844
- }
1845
- // group.<constraint>.<timeline> = keys[]
1846
- for (const group of NAMED_TIMELINE_GROUPS) {
1847
- if (!isObj(anim[group])) continue;
1848
- for (const [name, timelines] of Object.entries(anim[group] as Json)) {
1849
- const at = `animation "${animName}" ${group} constraint "${name}"`;
1850
- if (!isObj(timelines)) {
1851
- sawATimeline = true;
1852
- fail(
1853
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1854
- `${at}: this group maps a constraint to NAMED timelines ` +
1855
- `(${Object.keys(CHANNELS_BY_KIND[group]).join('/')}), and this one holds ${JSON.stringify(timelines)} — ` +
1856
- 'a bare key array here is the ik/transform shape and is walked as an object',
1857
- );
1858
- continue;
1859
- }
1860
- // The empty name is not a miss: it is the physics group's global form,
1861
- // which `SkeletonJson` loads as `constraintIndex -1` rather than looking
1862
- // anything up (`:1048-1054`), and which writes every physics constraint
1863
- // that declares the timeline's property global (issue #726). Refusing
1864
- // it as "no constraint called ''" was a sentence that is false about
1865
- // the file. What CAN be wrong with it is that it reaches nobody: the
1866
- // parser accepts it, the runtime walks every constraint, and none of
1867
- // them takes the key.
1868
- if (group === 'physics' && name === '') {
1869
- sawATimeline = true;
1870
- for (const [timelineName] of Object.entries(timelines)) {
1871
- const timeline = unnamedPhysicsTimeline(timelineName);
1872
- // A name the parser skips is skipped here too: no timeline exists
1873
- // to reach anybody, and whether the NAME is right is not a target
1874
- // question.
1875
- if (timeline === null || unnamedPhysicsReach(timeline, rawPhysics).length > 0) continue;
1876
- const resets = timeline instanceof PhysicsConstraintResetTimeline;
1877
- fail(
1878
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
1879
- `${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
1880
- `constraint ${resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
1881
- (rawPhysics.length === 0
1882
- ? 'the skeleton has no physics constraint'
1883
- : `none of ${rawPhysics.map((one) => `"${String(one.name)}"`).join(', ')} does`) +
1884
- ' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
1885
- );
1886
- }
1887
- checkKeys(at, Object.entries(timelines));
1888
- continue;
1889
- }
1890
- checkTarget(at, group, name, Object.entries(timelines));
1891
- }
1892
- }
1893
- }
1894
- if (!sawATimeline) {
1895
- return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'no animation carries a constraint timeline');
1896
- }
1897
- });
2296
+ //
2297
+ // ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
2298
+ // `./assertions/bodies/a34.ts`, over this file's constraint groups as the
2299
+ // raw JSON states them (`rawConstraintTargets`), and whom a physics timeline
2300
+ // naming no constraint reaches is still the parser's timeline asked of the
2301
+ // file's constraint objects, through the facts.
2302
+ check('A34_CONSTRAINT_TIMELINE_TARGETS', () =>
2303
+ raw ? a34ConstraintTimelineTargets(verdicts, rawConstraintTargets(raw)) : skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)'),
2304
+ );
1898
2305
 
1899
2306
  // --- A35: a deform key's run lands inside the attachment it edits ---------
1900
2307
  //
@@ -2141,19 +2548,7 @@ export function validate(input: ValidateInput): ValidateReport {
2141
2548
 
2142
2549
  // --- A12: no dark / two-colour tint --------------------------------------
2143
2550
  // Parsed, then silently ignored by spine-html.
2144
- check('A12_NO_DARK_COLOR', () => {
2145
- const slots = Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [];
2146
- for (const slot of slots) {
2147
- if (isObj(slot) && 'dark' in slot) {
2148
- fail('A12_NO_DARK_COLOR', `slot "${String(slot.name)}" declares a dark colour; the renderer ignores it`);
2149
- }
2150
- }
2151
- walkTimelines(raw, (path, kind, name) => {
2152
- if (kind === 'slot' && (name === 'rgba2' || name === 'rgb2')) {
2153
- fail('A12_NO_DARK_COLOR', `${path}: two-colour timeline "${name}" is silently ignored by the renderer`);
2154
- }
2155
- });
2156
- });
2551
+ check('A12_NO_DARK_COLOR', () => a12NoDarkColor(verdicts, rawSkeletonRoster(raw), rawSlotTimelines(raw)));
2157
2552
 
2158
2553
  // --- A05: curve arrays are 4 numbers per value channel --------------------
2159
2554
  check('A05_CURVE_ARRAY_LENGTH', () => {
@@ -2407,238 +2802,12 @@ export function validate(input: ValidateInput): ValidateReport {
2407
2802
  // reversals out of it and the report prints the rest. What stays here is the
2408
2803
  // SEVERITY and the exemption: the survey has no opinion about either, which
2409
2804
  // is what lets a report run it with no exemption at all.
2410
- check('A39_DEFORM_KEEPS_TRIANGLE_WINDING', () => {
2411
- if (!input.rig) {
2412
- return skip(
2413
- 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
2414
- 'no rig info (validating a bare directory), so the rig cannot say which slots fold on purpose',
2415
- );
2416
- }
2417
- const survey = surveyDeformKeys(data, new Set(input.rig.deformMayFold));
2418
- // 🚨 Which dial was turned, when rigc's two halves did not simply agree
2419
- // about that — and BEFORE any of the returns below, because every one of
2420
- // them is a run that posed frames through this dial (issue #427).
2421
- //
2422
- // The verdict used to live in `DeformReach.label`, which `explain` prints
2423
- // and nothing else does, so a `build`-only run — the normal loop, and the
2424
- // one an agent that cannot see the rig actually runs — never learned that
2425
- // the artifact and the probe named different fields.
2426
- //
2427
- // ⛔ Not a refusal, and the measurement rather than taste is why (#427).
2428
- // The frames this survey posed were each checked against `SliderPose.time`
2429
- // by the runtime itself, so a disagreement cannot make it pose one that
2430
- // does not happen; and the field it drives is the largest response the
2431
- // probe found, so it cannot make it miss one that does. What a
2432
- // disagreement CAN do is leave the rig naming a property no settable value
2433
- // of turns far enough — which is what `outside` measures and what an
2434
- // author can act on. A refusal would refuse a rig spine-core poses
2435
- // correctly at every key, with no edit that would make it green.
2436
- //
2437
- // 🔒 A tie is not a disagreement and gets a line that cannot be read as
2438
- // one: there the probe named no field, the artifact broke the tie, and the
2439
- // parent-45° geometry that reaches it is legitimate.
2440
- if (survey.dialTies.length) {
2441
- stats.deformDialsTied = survey.dialTies.length;
2442
- stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
2443
- }
2444
- if (survey.dialDisputes.length) {
2445
- stats.deformDialsDisagreed = survey.dialDisputes.length;
2446
- stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
2447
- }
2448
- /** A key this rule is refusing, by the triple that identifies it. */
2449
- const refusedKey = new Set<string>();
2450
- for (const key of survey.keys) {
2451
- // ⭐ The one thing this rule cannot say about a key that draws nothing.
2452
- // Its own message below states the harm as "draws its texture
2453
- // backwards", and that sentence is false when no pixel of the mesh
2454
- // lands at this time — the slot has faded to alpha 0, or shows another
2455
- // attachment. So the key is measured, reported and not gated (issue
2456
- // #401). Per key and per time: the SAME slot folding at full alpha in
2457
- // another animation, or at another key, is refused as before, which is
2458
- // what makes this a measurement rather than a second `deformMayFold`.
2459
- if (key.draw.blank !== null) continue;
2460
- // And the one thing it cannot say about a key at a time no dial selects
2461
- // (issue #407): the frame posed is not this key's, so its geometry
2462
- // belongs to some other time and a winding read off it would be a
2463
- // measurement of the wrong thing. Named below, on the stats line.
2464
- if (key.dial?.unreachable === true) continue;
2465
- if (key.reversed.length === 0) continue;
2466
- refusedKey.add(`${key.animation} ${key.slot} ${key.attachment} ${key.key}`);
2467
- const shown = key.reversed
2468
- .slice(0, 4)
2469
- .map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
2470
- const more = key.reversed.length > shown.length ? `, and ${key.reversed.length - shown.length} more` : '';
2471
- fail(
2472
- 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
2473
- // ⚠️ The frame is in the message whenever it is not the track, because
2474
- // the same key can be refused in one frame and passed over in another
2475
- // — two sliders applying one animation are two frames — and a message
2476
- // that named only the key would be ambiguous about which (issue #407).
2477
- `animation "${key.animation}"${frameClause(key.reach)} deform ${key.slot}/${key.attachment} ` +
2478
- `key ${key.key} (t=${key.time}s): ` +
2479
- `${key.reversed.length} of ${key.triangles} triangle(s) reverse winding — triangle ${shown.join('; triangle ')}` +
2480
- `${more}. The mesh has turned inside out there and draws its texture backwards` +
2481
- // The alpha is in the message whenever it is not full, because the
2482
- // one thing that would make this key exempt is alpha exactly 0 and
2483
- // an author who has already faded the part half out needs to be
2484
- // told that half is not none (issue #401).
2485
- (key.draw.alpha === 1
2486
- ? ''
2487
- : ` at alpha ${key.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 draws ` +
2488
- 'no pixels at all') +
2489
- '. Fix the key\'s ' +
2490
- 'offsets in the motion spec\'s deform timeline (a projection past its fold angle is the usual ' +
2491
- 'cause — docs/FACE.md §4.2 has the closed form), or, if this slot folds on purpose, declare it ' +
2492
- `in the rig spec as invariants.deformMayFold: [{ "slot": "${key.slot}", "why": … }]`,
2493
- );
2494
- }
2495
- // --- and the folds no key lands on (issue #403) ------------------------
2496
- let spanFolds = 0;
2497
- for (const span of survey.spans) {
2498
- if (span.fold === null) continue;
2499
- // Suppressed when a bounding key already says it: one defect, one
2500
- // message. The span check is here for what the keys cannot see.
2501
- const bounded = `${span.animation} ${span.slot} ${span.attachment} `;
2502
- if (refusedKey.has(bounded + span.fromKey) || refusedKey.has(bounded + span.toKey)) continue;
2503
- spanFolds++;
2504
- const at = span.fold;
2505
- const shown = at.measure.reversed
2506
- .slice(0, 4)
2507
- .map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
2508
- const more =
2509
- at.measure.reversed.length > shown.length ? `, and ${at.measure.reversed.length - shown.length} more` : '';
2510
- // A stepped segment interpolates NOTHING — it holds the earlier key's
2511
- // geometry across the whole span — so saying "the runtime interpolates"
2512
- // there would be telling the author to look for a defect in the wrong
2513
- // place. What changed across a stepped span is what the slot DRAWS.
2514
- const held = span.curve === 'stepped';
2515
- fail(
2516
- 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
2517
- `animation "${span.animation}"${frameClause(span.reach)} deform ${span.slot}/${span.attachment} ` +
2518
- `BETWEEN key ${span.fromKey} ` +
2519
- `(t=${span.fromTime}s) and key ${span.toKey} (t=${span.toTime}s), at t=${at.time.toFixed(6)}s` +
2520
- (held ? ' (a stepped segment)' : ` — ${(at.percent * 100).toFixed(1)}% of the way from one to the other`) +
2521
- `: ${at.measure.reversed.length} of ${at.measure.triangles} triangle(s) reverse winding — ` +
2522
- `triangle ${shown.join('; triangle ')}${more}. NO KEY LANDS THERE: ` +
2523
- (held
2524
- ? `a stepped segment interpolates nothing, it HOLDS key ${span.fromKey}'s geometry across the whole ` +
2525
- 'span — so the fold is that key\'s and what changes here is what the slot draws'
2526
- : 'the runtime interpolates between the two keys, and the mesh is inside out for part of the way') +
2527
- ', drawing its texture backwards' +
2528
- (at.measure.draw.alpha === 1
2529
- ? ''
2530
- : ` at alpha ${at.measure.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 ` +
2531
- 'draws no pixels at all') +
2532
- '. ' +
2533
- (held
2534
- ? `Fix key ${span.fromKey}'s offsets, or keep the slot drawing nothing for as long as it holds them`
2535
- : 'Add a key inside the span so the geometry the runtime passes through is geometry you wrote, or ' +
2536
- "move the two keys' offsets closer together (a projection past its fold angle is the usual cause " +
2537
- '— docs/FACE.md §4.2 has the closed form)') +
2538
- (at.measure.draw.alpha === 1
2539
- ? ''
2540
- : '; if the part is being faded out over this turn, land the alpha-0 key BEFORE the folding key ' +
2541
- 'rather than on it, so every frame that folds is a frame that draws nothing (docs/FACE.md §9.2)') +
2542
- `, or, if this slot folds on purpose, declare it in the rig spec as invariants.deformMayFold: ` +
2543
- `[{ "slot": "${span.slot}", "why": … }]`,
2544
- );
2545
- }
2546
- if (survey.timelines === 0) {
2547
- return skip('A39_DEFORM_KEEPS_TRIANGLE_WINDING', 'no animation carries a deform timeline');
2548
- }
2549
- if (survey.keys.length === 0) {
2550
- const why = [
2551
- survey.exempted.length ? `the rig declares ${survey.exempted.join(', ')} as deformMayFold` : '',
2552
- survey.notAMesh.length ? `${survey.notAMesh.join(', ')} deform an attachment with no triangles` : '',
2553
- ].filter(Boolean);
2554
- return skip(
2555
- 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
2556
- `no deform timeline here has a winding to keep: ${why.join('; ') || 'every mesh keyed has no triangles'}`,
2557
- );
2558
- }
2559
- // ⚠️ Silence is not a pass. A key passed over because nothing of it is
2560
- // drawn has to be visible on a green run too — this is the only surface
2561
- // `validate` has, and `explain`'s DEFORM block prints the whole sentence
2562
- // beside the key's own figures.
2563
- // ⚠️ Unreachable first and `blank` second, in the survey's own order, so
2564
- // the two counts partition the ungated keys instead of double-counting a
2565
- // key that is both.
2566
- const unreachable = survey.keys.filter((k) => k.dial?.unreachable === true);
2567
- const blank = survey.keys.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
2568
- const ungated = blank.length + unreachable.length;
2569
- const name = (k: (typeof survey.keys)[number]): string =>
2570
- `${k.animation}/${k.slot}/${k.attachment}#${k.key}:${k.draw.showsThisMesh ? 'alpha0' : 'notShown'}`;
2571
- // ⚠️ `&& spanFolds === 0` because a rig whose every key draws nothing can
2572
- // still fold at a time between two of them that DOES draw — that is issue
2573
- // #403's own case, and a SKIP printed over a refusal would be this rule
2574
- // reporting "nothing to measure" about the thing it just measured.
2575
- if (ungated === survey.keys.length && spanFolds === 0) {
2576
- const first = blank[0] ?? unreachable[0];
2577
- return skip(
2578
- 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
2579
- `no deform key here is measurable in the frame its animation is reached in — ` +
2580
- `${first.animation} ${first.slot}/${first.attachment} key ${first.key}: ` +
2581
- `${first.draw.blank ?? unreachableWhy(first)}` +
2582
- (survey.keys.length > 1 ? `, and ${survey.keys.length - 1} more key(s) like it` : '') +
2583
- (unreachable.length
2584
- ? `. ${unreachable.length} of them at a time no dial selects, which is a rig defect this rule does ` +
2585
- 'not refuse and does not pass over in silence either'
2586
- : '') +
2587
- (survey.spans.length
2588
- ? `. The ${survey.spans.length} span(s) between them were scanned too and none folds where anything ` +
2589
- 'is drawn'
2590
- : ''),
2591
- );
2592
- }
2593
- stats.deformKeysMeasured = survey.keys.length - ungated;
2594
- stats.deformTrianglesMeasured = survey.trianglesMeasured;
2595
- stats.deformTrianglesCollapsed = survey.collapsed;
2596
- // Which frame each animation was posed in (issue #407) — printed only when
2597
- // a slider chose one, because on every other rig it says "a track" about
2598
- // every animation and a stats line that never varies is not a reading.
2599
- const frames = [...new Map(survey.keys.map((k) => [`${k.animation}/${k.reach.slider ?? 'track'}`, k])).values()];
2600
- if (frames.some((k) => k.reach.kind === 'slider')) {
2601
- stats.deformFrames = frames
2602
- .map((k) => `${k.animation}:${k.reach.kind === 'slider' ? `slider/${k.reach.slider}` : 'track'}`)
2603
- .join(',');
2604
- }
2605
- if (blank.length) {
2606
- stats.deformKeysNotDrawn = blank.length;
2607
- stats.deformNotDrawn = blank.map(name).join(',');
2608
- if (survey.notDrawnReversed) stats.deformNotDrawnReversed = survey.notDrawnReversed;
2609
- }
2610
- // 🚨 A key at a time no dial can select is NOT a pass and NOT a refusal —
2611
- // it is a rig whose slider cannot reach its own animation's key, named
2612
- // here so a green run cannot be read as having measured it (issue #407).
2613
- if (unreachable.length) {
2614
- stats.deformKeysUnreachable = unreachable.length;
2615
- stats.deformUnreachable = unreachable
2616
- .map((k) => `${k.animation}/${k.slot}/${k.attachment}#${k.key}@${k.dial?.applied.toFixed(6) ?? '?'}`)
2617
- .join(',');
2618
- if (survey.notReachableReversed) stats.deformUnreachableReversed = survey.notReachableReversed;
2619
- }
2620
- if (survey.exempted.length) stats.deformFoldExempt = survey.exempted.join(',');
2621
- // ⚠️ The between-keys scan on the stats line, on a GREEN run too (issue
2622
- // #403). `deformSpansScanned` is the positive control an agent can read —
2623
- // a scan that ran and found nothing has to be distinguishable from a scan
2624
- // that never ran — and `deformSpanProbes` is what it cost: 0 on a rig the
2625
- // closed form flags nothing in, one posed measurement per flagged window
2626
- // otherwise.
2627
- stats.deformSpansScanned = survey.spans.length;
2628
- // ⚠️ And the ones it could NOT scan, for the same reason the line above
2629
- // exists: a span bounded by a key at a time no dial selects would be
2630
- // solved over two poses of some other time, so it is skipped — and a skip
2631
- // nobody can see is the silence this whole surface is against (#407).
2632
- if (survey.spansNotScanned) stats.deformSpansNotScanned = survey.spansNotScanned;
2633
- if (survey.spanProbes) stats.deformSpanProbes = survey.spanProbes;
2634
- if (survey.spansNotDrawn) stats.deformSpansNotDrawn = survey.spansNotDrawn;
2635
- // A prediction nothing reproduced. Never a refusal — that would be the
2636
- // false red issues #44 and #262 already cost this file — and never a
2637
- // silence either: the one case that reaches it is a weighted mesh whose
2638
- // bones move across the span, where the closed form's fixed-pose
2639
- // assumption is the thing that did not hold.
2640
- if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
2641
- });
2805
+ //
2806
+ // Since issue #1025 (cut 4c-3) "here" is `./assertions/bodies/a39.ts`, which
2807
+ // takes the survey as a fact: `spineDeformSurvey` hands it spine-core's, and
2808
+ // the model side the core's over the model document — the same body, and the
2809
+ // survey the two hand it held to one by `tools/survey_hashes.ts`.
2810
+ check('A39_DEFORM_KEEPS_TRIANGLE_WINDING', () => a39DeformKeepsTriangleWinding(verdicts, spineDeformSurvey(data), input.rig));
2642
2811
 
2643
2812
  // --- A23, A41, A36, A37, A47, A48: a constraint that does nothing, quietly -
2644
2813
  //
@@ -2785,127 +2954,13 @@ export function validate(input: ValidateInput): ValidateReport {
2785
2954
  // measurement instead of two exceptions. A list of unobservable spellings
2786
2955
  // written into this file would have been the hand-kept table that produced
2787
2956
  // the defect above.
2788
- check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => {
2789
- const sliders = data.constraints.filter((c) => c instanceof SliderData);
2790
- if (sliders.length < 2) {
2791
- return skip(
2792
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2793
- `the skeleton declares ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'}, and one slider has nothing to compose with`,
2794
- );
2795
- }
2796
- // Clause 1 of the "could this be correct?" list above.
2797
- // "Keyed at all" rather than "keyed live", and on purpose: this clause asks
2798
- // whether the mix can MOVE from its setup value, so any mix timeline
2799
- // disqualifies it — the question `keyedBy` answered here, through the one
2800
- // reading `switchedOn` gives (`./assertions/constraint_words.ts`, over
2801
- // the same facts the moved constraint bodies read — issue #1025), and
2802
- // unchanged by issue #752.
2803
- const mixKeyed = switchedOn(constraintFacts(), (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, () => true);
2804
- const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !mixKeyed.has(data.constraints.indexOf(s)));
2805
- if (authoritative.length < 2) {
2806
- return skip(
2807
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2808
- `${authoritative.length} of the ${sliders.length} slider constraints apply at full authority; below mix 1 an ` +
2809
- 'apply is a lerp from the current pose rather than an overwrite, so what the others do to a shared property is a weighting',
2810
- );
2811
- }
2812
- /** Which skins switch a slider on, or null when it is active under every skin. */
2813
- const skinsOf = new Map<SliderData, Set<string> | null>();
2814
- for (const slider of authoritative) {
2815
- if (!slider.skinRequired) {
2816
- skinsOf.set(slider, null);
2817
- continue;
2818
- }
2819
- const names = new Set<string>();
2820
- for (const skin of data.skins) {
2821
- if (skin.constraints.includes(slider)) names.add(skin.name);
2822
- }
2823
- skinsOf.set(slider, names);
2824
- }
2825
- /** Clause 2: can these two ever run in the same frame? */
2826
- const canOverlap = (a: SliderData, b: SliderData): boolean => {
2827
- const skinsA = skinsOf.get(a) ?? null;
2828
- const skinsB = skinsOf.get(b) ?? null;
2829
- if (!skinsA || !skinsB) return true;
2830
- for (const name of skinsA) {
2831
- if (skinsB.has(name)) return true;
2832
- }
2833
- return false;
2834
- };
2835
- /** What a property id points at, in the words the rig spec uses. */
2836
- const describe = (timeline: Timeline, id: string): string => {
2837
- const property = Property[Number(id.split('|')[0])] ?? id;
2838
- if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
2839
- if (timeline instanceof DeformTimeline) {
2840
- return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
2841
- }
2842
- if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
2843
- if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
2844
- return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
2845
- }
2846
- return `the skeleton's ${property}`;
2847
- };
2848
- /** property id -> the sliders whose animation keys it, in constraints-array order. */
2849
- const byProperty = new Map<string, Array<{ slider: SliderData; timeline: Timeline }>>();
2850
- for (const slider of authoritative) {
2851
- const seen = new Set<string>();
2852
- for (const timeline of slider.animation?.timelines ?? []) {
2853
- for (const id of timeline.propertyIds) {
2854
- if (seen.has(id)) continue;
2855
- seen.add(id);
2856
- byProperty.set(id, [...(byProperty.get(id) ?? []), { slider, timeline }]);
2857
- }
2858
- }
2859
- }
2860
- /** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
2861
- const behaviour = new Map<Timeline, TimelineAddBehaviour>();
2862
- const addBehaviourOf = (timeline: Timeline): TimelineAddBehaviour => {
2863
- const known = behaviour.get(timeline);
2864
- if (known !== undefined) return known;
2865
- const measured = timelineAddBehaviour(data, timeline);
2866
- behaviour.set(timeline, measured);
2867
- return measured;
2868
- };
2869
- let shared = 0;
2870
- for (const [id, users] of byProperty) {
2871
- if (users.length < 2) continue;
2872
- shared++;
2873
- const at = (slider: SliderData): string =>
2874
- `"${slider.name}" (constraints[${data.constraints.indexOf(slider)}], additive: ${String(slider.additive)})`;
2875
- const chain = users.map((u) => at(u.slider)).join(', ');
2876
- for (let j = 1; j < users.length; j++) {
2877
- const later = users[j];
2878
- const composes = addBehaviourOf(later.timeline);
2879
- if (composes === 'accumulates' && later.slider.additive) continue;
2880
- // Nothing a second slider could take away: applied with the arguments
2881
- // a slider passes — `firedEvents` null among them — this timeline
2882
- // moves no pose at all.
2883
- if (composes === 'inert') continue;
2884
- const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
2885
- if (!erased.length) continue;
2886
- const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
2887
- const why =
2888
- composes === 'accumulates'
2889
- ? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
2890
- 'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
2891
- '`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
2892
- `slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
2893
- `property from one slider only. [measured] \`${later.timeline.constructor.name}.apply\` posed twice with ` +
2894
- '`add` set accumulates, so that flag is the repair here'
2895
- : `the ${Property[Number(id.split('|')[0])] ?? id} timeline they share writes its value outright whatever the ` +
2896
- `flags say — [measured] \`${later.timeline.constructor.name}.apply\` posed twice with \`add\` set left the ` +
2897
- 'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
2898
- 'property from one slider only, or move both edits into the one animation a single slider applies';
2899
- fail(
2900
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2901
- `${describe(later.timeline, id)} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
2902
- `one of them applies at mix 1. Today ${at(later.slider)} wins that property and ${erasedNames} ` +
2903
- `nothing to it: ${why}.`,
2904
- );
2905
- }
2906
- }
2907
- stats.sliderSharedTargets = shared;
2908
- });
2957
+ //
2958
+ // ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
2959
+ // `./assertions/bodies/a40.ts`, over the sliders and their animations'
2960
+ // timelines (`spineSliderComposition`) and the constraint facts, and what a
2961
+ // timeline does with `add` is still this probe, `timelineAddBehaviour`,
2962
+ // asked by the body through the facts.
2963
+ check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => a40SlidersComposeOnASharedTarget(verdicts, spineSliderComposition(data), constraintFacts()));
2909
2964
 
2910
2965
  // --- A42: a dial that drives a constraint the array already ran ---------
2911
2966
  //
@@ -2989,271 +3044,104 @@ export function validate(input: ValidateInput): ValidateReport {
2989
3044
  check('A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED', () => a38SkinMembersAreSkinRequired(verdicts, data));
2990
3045
 
2991
3046
  // --- A09: compiled duration == declared duration (rule 4) --------------
2992
- check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => {
2993
- // Without the spec there is no declared duration to compare the compiled
2994
- // one against, and returning here used to count as a PASS — a gate saying
2995
- // it checked something it never looked at.
2996
- if (!input.declaredDurations) {
2997
- return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', 'no motion spec supplied, so no declared duration to compare against');
2998
- }
2999
- // Same trap one level down. A **static rig** — a skeleton that exists to
3000
- // be posed and carries no animation at all, which is what
3001
- // `1-weight-and-mass`'s second export is — declares nothing and loads
3002
- // nothing, so both loops below iterate zero times and the assertion
3003
- // reported PASS. That is the vacuous green this report is built to refuse:
3004
- // there is no duration here, and saying so is the honest answer.
3005
- if (Object.keys(input.declaredDurations).length === 0 && data.animations.length === 0) {
3006
- return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', SKIP_NO_DECLARED_DURATION);
3007
- }
3008
- for (const [name, declared] of Object.entries(input.declaredDurations)) {
3009
- const anim = data.findAnimation(name);
3010
- if (!anim) {
3011
- fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `spec declares animation "${name}" but the skeleton has none`);
3012
- continue;
3013
- }
3014
- // Two arms, and the asymmetry is the point.
3015
- //
3016
- // UNDERSHOOT is R7's question — is the declared duration wrong? An
3017
- // animation may hold its final pose, so a last key a little before the
3018
- // end is ordinary and a frame of that is slack.
3019
- //
3020
- // OVERSHOOT is a different question with a different tolerance.
3021
- // `anim.duration` IS the largest key time (`SkeletonJson.ts:1261` takes
3022
- // the max over every timeline's own duration), so a loaded duration past
3023
- // the declared one means a KEY is past it — and nothing that plays the
3024
- // animation for the duration it declares will ever reach that key. Rung
3025
- // 6 lost a one-frame attachment reveal to a key 3.4e-5 s past the end,
3026
- // 1/500 of FRAME, which this comparison read as agreement (issue #54).
3027
- // `compile.ts` refuses that per timeline now; this is the same rule held
3028
- // against a skeleton the compiler never saw.
3029
- // The slack is one float32 step at the declared duration, and it is the
3030
- // same function the compiler's Rule 4 refuses on (`float32Step`, in
3031
- // `timelines.ts`, which says why it is a step of the float and no longer
3032
- // a fixed 1e-6 plus one).
3033
- const slack = float32Step(declared);
3034
- const past = anim.duration - declared;
3035
- if (past > slack) {
3036
- const late = anim.timelines.filter((t) => t.getDuration() - declared > slack).length;
3037
- fail(
3038
- 'A09_ANIMATION_DURATION_MATCHES_SPEC',
3039
- `animation "${name}" has ${late} timeline(s) keyed past the declared duration ${declared}s — ` +
3040
- `the last key is at ${anim.duration}s, ${past.toFixed(6)}s late, so nothing ever samples it`,
3041
- );
3042
- } else if (declared - anim.duration > FRAME) {
3043
- fail(
3044
- 'A09_ANIMATION_DURATION_MATCHES_SPEC',
3045
- `animation "${name}" loaded duration ${anim.duration}s, spec declares ${declared}s`,
3046
- );
3047
- }
3048
- }
3049
- for (const anim of data.animations) {
3050
- if (!(anim.name in input.declaredDurations)) {
3051
- fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `skeleton has animation "${anim.name}" with no spec entry`);
3052
- }
3053
- }
3054
- });
3047
+ //
3048
+ // The body is `./assertions/bodies/a09.ts` since issue #1025 (cut 4c-3);
3049
+ // what it reads off the loaded skeleton is `spineAnimationDurations`.
3050
+ check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => a09AnimationDurationMatchesSpec(verdicts, spineAnimationDurations(data), input.declaredDurations));
3055
3051
 
3056
3052
  // --- A10: step every animation and look for NaN ------------------------
3057
- check('A10_NO_NAN_AFTER_STEPPING', () => {
3058
- // Two clauses, and since issue #902 each is read on its own subject. The
3059
- // SETUP POSE is posed from the bones, so it has something to read on any
3060
- // skeleton that carries one; the STEPPED FRAMES are posed once per
3061
- // animation, so a skeleton with none has nothing to step. This used to
3062
- // skip the whole rule on the second fact alone, which was A15's pattern
3063
- // (#580) for a rule whose only clause was the stepping — and #882 gave it
3064
- // the setup clause without moving the skip. Measured on the static probe
3065
- // with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
3066
- // printed this rule as SKIP and the run green under both profiles, and
3067
- // `render` refused the same file by the bone. A static rig's setup pose
3068
- // is the whole of what it shows, so that was the one pose nothing read.
3069
- //
3070
- // ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
3071
- // and A38 carry: the rule SKIPs only when neither clause has anything to
3072
- // measure, the clause that ran decides PASS or FAIL, and the clause that
3073
- // had nothing is named on the stats line (`nanStepping=skipped`, beside
3074
- // `animations=0`) the way A47 names a constraint it did not measure. A
3075
- // PASS row carries no detail, which is why the stats line is where.
3076
- //
3077
- // ⚠️ A09 does not follow, and that is its own name read at its word: a
3078
- // declared duration is a fact about an animation and nothing else, so a
3079
- // static rig still gives it nothing at all.
3080
- if (data.animations.length === 0 && data.bones.length === 0) {
3081
- return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_POSE);
3082
- }
3083
- /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
3084
- const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
3085
-
3086
- // -- the bone's inheritance mode, posed at every `inherit` key ---------
3087
- //
3088
- // 🚨 The one bone timeline whose value is a NAME, and the only one that
3089
- // can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
3090
- // through `Utils.enumValue`, which folds the first letter's case and
3091
- // nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
3092
- // `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
3093
- // `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
3094
- // and the bone keeps whatever world matrix it had — measured on a forged
3095
- // two-bone chain: at the key the child's `a,b,c,d` equal the setup
3096
- // Normal-mode pose to the last digit while the file says `noScale`. The
3097
- // world position stays finite, so the loop below never saw it (#733).
3098
- //
3099
- // ⚠️ Posed AT each key rather than read off the stepping loop, because a
3100
- // stepped value lives from its key to the next one and a sampling grid
3101
- // can step over a short span entirely. What is judged is whether the
3102
- // posed value IS a mode — which is this assertion's name exactly — and
3103
- // not which mode: for any spelling the lookup resolves, the mode posed
3104
- // is the mode written by construction of the same lookup, so an equality
3105
- // here would be the parser agreeing with itself.
3106
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
3107
- let unresolved = 0;
3108
- for (const [animName, rawAnim] of Object.entries(rawAnimations)) {
3109
- if (!isObj(rawAnim) || !isObj(rawAnim.bones)) continue;
3110
- for (const [boneName, timelines] of Object.entries(rawAnim.bones)) {
3111
- if (!isObj(timelines) || !Array.isArray(timelines.inherit)) continue;
3112
- if (!data.findAnimation(animName) || !data.findBone(boneName)) continue;
3113
- for (const rawKey of timelines.inherit as unknown[]) {
3114
- if (!isObj(rawKey)) continue;
3115
- const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
3116
- const skeleton = new Skeleton(data);
3117
- const state = new AnimationState(new AnimationStateData(data));
3118
- state.setAnimation(0, animName, false);
3119
- skeleton.setupPose();
3120
- skeleton.update(0);
3121
- skeleton.updateWorldTransform(Physics.reset);
3122
- state.update(time);
3123
- state.apply(skeleton);
3124
- skeleton.update(time);
3125
- skeleton.updateWorldTransform(Physics.update);
3126
- const posed = skeleton.findBone(boneName)?.appliedPose.inherit;
3127
- if (isMode(posed)) continue;
3128
- unresolved++;
3129
- fail(
3130
- 'A10_NO_NAN_AFTER_STEPPING',
3131
- `animation "${animName}" bone "${boneName}" inherit (t=${time}): the bone poses inheritance mode ` +
3132
- `${String(posed)} — the key spells ${JSON.stringify(rawKey.inherit)}, which the runtime's mode lookup ` +
3133
- `does not resolve (${BONE_INHERIT_KNOWN}), so no mode applies until the next key and the bone keeps ` +
3134
- 'the world rotation, scale and shear it had',
3135
- );
3136
- }
3137
- }
3138
- }
3139
- if (unresolved > 0) return;
3140
-
3141
- // -- the whole world transform, at the setup pose and every stepped frame
3142
- //
3143
- // 🚨 Read through `nonFiniteOfPosed`, the scan `render` refuses on, and
3144
- // not a second opinion of it (issue #882). This loop read `worldX` and
3145
- // `worldY` alone until then, and a bone at `rotation: 1e309` — in its setup
3146
- // pose or as a `rotate` key — poses a finite position over a NaN `a`, `b`,
3147
- // `c` and `d` whenever it has no child offset from it: measured on every
3148
- // leaf bone of the three generated probes, 20 of 20 green through the whole
3149
- // gate, and `render` then refused the same file by the bone. So the gate
3150
- // said the skeleton was fine and the renderer said it was not, which is two
3151
- // definitions of one word. The scan reads the six terms of every bone, then
3152
- // the vertices of every region and mesh shown — because a bone can be
3153
- // finite over a vertex that is not (a `scaleX` chain whose product stays
3154
- // under the largest double while every corner of the child's region
3155
- // passes it) — and names the bone or the vertex, the term and the frame.
3156
- //
3157
- // ⚠️ The setup pose is its own surface and is read as such: every frame
3158
- // below is posed AFTER `state.apply`, so a bone the animation keys from
3159
- // t=0 never shows its setup value to the loop, and a runtime that shows
3160
- // the rig at rest does show it.
3161
- //
3162
- // 🔸 Posed once, before any animation is set, rather than once per
3163
- // animation as it was until #902: `setAnimation` does not touch the
3164
- // skeleton, so every animation's copy of this pose was the same pose, and
3165
- // the first of them was the only one ever read.
3166
- if (data.animations.length === 0) stats.nanStepping = 'skipped';
3167
- const atRest = new Skeleton(data);
3168
- atRest.setupPose();
3169
- atRest.update(0);
3170
- atRest.updateWorldTransform(Physics.reset);
3171
- const atSetup = nonFiniteOfPosed('the setup pose', atRest);
3172
- if (atSetup !== null) {
3173
- fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
3174
- return;
3175
- }
3176
-
3177
- /**
3178
- * What a posed frame shows that the world-transform scan does not read: a
3179
- * bone posing no inheritance mode, and a slot colour that is not finite.
3180
- * `where` leads the sentence — the animation's name at a stepped frame,
3181
- * as it always has, or `the setup pose` on a skeleton with no animation.
3182
- */
3183
- const poseDefect = (where: string, skeleton: Skeleton): string | null => {
3184
- for (const bone of skeleton.bones) {
3185
- const pose = bone.appliedPose;
3186
- // The setup half of the inherit-key clause above: a bone's own
3187
- // `inherit` goes through the same lookup, and a miss there loads
3188
- // `undefined` into the setup pose, which every frame copies. Only
3189
- // reachable on a file rigc did not write — `parseRigSpec` refuses the
3190
- // spelling.
3191
- if (!isMode(pose.inherit)) {
3192
- const rawBone = Array.isArray(raw?.bones)
3193
- ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
3194
- : undefined;
3195
- return (
3196
- `${where}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
3197
- `spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
3198
- `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
3199
- 'are never computed — they stay 0 and everything the bone carries collapses to a point'
3200
- );
3201
- }
3202
- }
3203
- for (const slot of skeleton.slots) {
3204
- const c = slot.appliedPose.color;
3205
- if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) return `${where}: slot "${slot.data.name}" colour is non-finite`;
3206
- // The other colour a slot poses, and it was outside this loop until
3207
- // issue #690 for the reason every gap here has: nothing emitted one.
3208
- // `null` is the ordinary case — a slot with no `dark` allocates no
3209
- // dark colour at all — and is not a reading to make, so it is
3210
- // skipped rather than treated as zero.
3211
- const d = slot.appliedPose.darkColor;
3212
- if (d !== null && ![d.r, d.g, d.b].every(Number.isFinite)) {
3213
- return `${where}: slot "${slot.data.name}" dark colour is non-finite`;
3214
- }
3215
- }
3216
- return null;
3217
- };
3218
-
3219
- // The stepping half, and on a static rig there is nothing to step: the
3220
- // setup pose is then the only frame the skeleton has, and the two readings
3221
- // a stepped frame gets are made on it instead, so a colour or an
3222
- // inheritance mode the rig shows at rest is not left to `render`.
3223
- if (data.animations.length === 0) {
3224
- const atRestDefect = poseDefect('the setup pose', atRest);
3225
- if (atRestDefect !== null) fail('A10_NO_NAN_AFTER_STEPPING', atRestDefect);
3226
- return;
3227
- }
3228
- for (const anim of data.animations) {
3229
- const skeleton = new Skeleton(data);
3230
- const state = new AnimationState(new AnimationStateData(data));
3231
- state.setAnimation(0, anim.name, true);
3232
- skeleton.setupPose();
3233
- skeleton.update(0);
3234
- skeleton.updateWorldTransform(Physics.reset);
3235
- const step = Math.max(anim.duration, 1) / STEP_FRAMES;
3236
- for (let i = 0; i < STEP_FRAMES; i++) {
3237
- state.update(step);
3238
- state.apply(skeleton);
3239
- skeleton.update(step);
3240
- skeleton.updateWorldTransform(Physics.update);
3241
- const found = nonFiniteOfPosed(
3242
- `animation ${JSON.stringify(anim.name)} frame ${i + 1} of ${STEP_FRAMES} (t=${((i + 1) * step).toFixed(4)}s)`,
3243
- skeleton,
3244
- );
3245
- if (found !== null) {
3246
- fail('A10_NO_NAN_AFTER_STEPPING', found);
3247
- return;
3248
- }
3249
- const defect = poseDefect(anim.name, skeleton);
3250
- if (defect !== null) {
3251
- fail('A10_NO_NAN_AFTER_STEPPING', defect);
3252
- return;
3253
- }
3254
- }
3255
- }
3256
- });
3053
+ // The body is `./assertions/bodies/a10.ts` since issue #1025 (cut 4c-5a); what it
3054
+ // reads off the loaded skeleton is `spineSteppedPoses` (and the raw JSON's bone
3055
+ // timelines, `rawBoneTimelines`). The argument for each clause, as it stood
3056
+ // inside the body:
3057
+ //
3058
+ // Two clauses, and since issue #902 each is read on its own subject. The
3059
+ // SETUP POSE is posed from the bones, so it has something to read on any
3060
+ // skeleton that carries one; the STEPPED FRAMES are posed once per
3061
+ // animation, so a skeleton with none has nothing to step. This used to
3062
+ // skip the whole rule on the second fact alone, which was A15's pattern
3063
+ // (#580) for a rule whose only clause was the stepping — and #882 gave it
3064
+ // the setup clause without moving the skip. Measured on the static probe
3065
+ // with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
3066
+ // printed this rule as SKIP and the run green under both profiles, and
3067
+ // `render` refused the same file by the bone. A static rig's setup pose
3068
+ // is the whole of what it shows, so that was the one pose nothing read.
3069
+ //
3070
+ // ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
3071
+ // and A38 carry: the rule SKIPs only when neither clause has anything to
3072
+ // measure, the clause that ran decides PASS or FAIL, and the clause that
3073
+ // had nothing is named on the stats line (`nanStepping=skipped`, beside
3074
+ // `animations=0`) the way A47 names a constraint it did not measure. A
3075
+ // PASS row carries no detail, which is why the stats line is where.
3076
+ //
3077
+ // ⚠️ A09 does not follow, and that is its own name read at its word: a
3078
+ // declared duration is a fact about an animation and nothing else, so a
3079
+ // static rig still gives it nothing at all.
3080
+ //
3081
+ // -- the bone's inheritance mode, posed at every `inherit` key ---------
3082
+ //
3083
+ // 🚨 The one bone timeline whose value is a NAME, and the only one that
3084
+ // can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
3085
+ // through `Utils.enumValue`, which folds the first letter's case and
3086
+ // nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
3087
+ // `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
3088
+ // `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
3089
+ // and the bone keeps whatever world matrix it had — measured on a forged
3090
+ // two-bone chain: at the key the child's `a,b,c,d` equal the setup
3091
+ // Normal-mode pose to the last digit while the file says `noScale`. The
3092
+ // world position stays finite, so the loop below never saw it (#733).
3093
+ //
3094
+ // ⚠️ Posed AT each key rather than read off the stepping loop, because a
3095
+ // stepped value lives from its key to the next one and a sampling grid
3096
+ // can step over a short span entirely. What is judged is whether the
3097
+ // posed value IS a mode — which is this assertion's name exactly — and
3098
+ // not which mode: for any spelling the lookup resolves, the mode posed
3099
+ // is the mode written by construction of the same lookup, so an equality
3100
+ // here would be the parser agreeing with itself.
3101
+ //
3102
+ // -- the whole world transform, at the setup pose and every stepped frame
3103
+ //
3104
+ // 🚨 Read through `firstNonFinite` (`./nonfinite.ts`) over `posedNumbersOf`,
3105
+ // the scan `render` refuses on, and not a second opinion of it (issue #882). This loop read `worldX` and
3106
+ // `worldY` alone until then, and a bone at `rotation: 1e309` — in its setup
3107
+ // pose or as a `rotate` key — poses a finite position over a NaN `a`, `b`,
3108
+ // `c` and `d` whenever it has no child offset from it: measured on every
3109
+ // leaf bone of the three generated probes, 20 of 20 green through the whole
3110
+ // gate, and `render` then refused the same file by the bone. So the gate
3111
+ // said the skeleton was fine and the renderer said it was not, which is two
3112
+ // definitions of one word. The scan reads the six terms of every bone, then
3113
+ // the vertices of every region and mesh shown — because a bone can be
3114
+ // finite over a vertex that is not (a `scaleX` chain whose product stays
3115
+ // under the largest double while every corner of the child's region
3116
+ // passes it) — and names the bone or the vertex, the term and the frame.
3117
+ //
3118
+ // ⚠️ The setup pose is its own surface and is read as such: every frame
3119
+ // below is posed AFTER `state.apply`, so a bone the animation keys from
3120
+ // t=0 never shows its setup value to the loop, and a runtime that shows
3121
+ // the rig at rest does show it.
3122
+ //
3123
+ // 🔸 Posed once, before any animation is set, rather than once per
3124
+ // animation as it was until #902: `setAnimation` does not touch the
3125
+ // skeleton, so every animation's copy of this pose was the same pose, and
3126
+ // the first of them was the only one ever read.
3127
+ //
3128
+ // The setup half of the inherit-key clause above: a bone's own
3129
+ // `inherit` goes through the same lookup, and a miss there loads
3130
+ // `undefined` into the setup pose, which every frame copies. Only
3131
+ // reachable on a file rigc did not write — `parseRigSpec` refuses the
3132
+ // spelling.
3133
+ //
3134
+ // The other colour a slot poses, and it was outside this loop until
3135
+ // issue #690 for the reason every gap here has: nothing emitted one.
3136
+ // `null` is the ordinary case — a slot with no `dark` allocates no
3137
+ // dark colour at all — and is not a reading to make, so it is
3138
+ // skipped rather than treated as zero.
3139
+ //
3140
+ // The stepping half, and on a static rig there is nothing to step: the
3141
+ // setup pose is then the only frame the skeleton has, and the two readings
3142
+ // a stepped frame gets are made on it instead, so a colour or an
3143
+ // inheritance mode the rig shows at rest is not left to `render`.
3144
+ check('A10_NO_NAN_AFTER_STEPPING', () => a10NoNanAfterStepping(verdicts, rawBoneTimelines(raw), spineSteppedPoses(raw, data)));
3257
3145
 
3258
3146
  // --- A43: the two-colour tint, read back off the runtime ----------------
3259
3147
  //
@@ -3289,188 +3177,11 @@ export function validate(input: ValidateInput): ValidateReport {
3289
3177
  // ⚠️ The hex is parsed HERE rather than through `Color.fromString`, and that
3290
3178
  // is the point of the clause: a check that read the required value out of the
3291
3179
  // same parser it is checking would agree with it whatever it did.
3292
- check('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', () => {
3293
- /** `rrggbb`, or `rrggbbaa` whose last pair the format drops. Null for anything else. */
3294
- const readDarkHex = (hex: string): [number, number, number] | null => {
3295
- const body = hex.startsWith('#') ? hex.slice(1) : hex;
3296
- if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
3297
- return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
3298
- };
3299
- /** `rrggbbaa`, or `rrggbb` the runtime opens at alpha 1. Null for anything else. */
3300
- const readLightHex = (hex: string): [number, number, number, number] | null => {
3301
- const body = hex.startsWith('#') ? hex.slice(1) : hex;
3302
- if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
3303
- const rgb = [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255);
3304
- return [rgb[0], rgb[1], rgb[2], body.length === 8 ? Number.parseInt(body.slice(6, 8), 16) / 255 : 1];
3305
- };
3306
- /**
3307
- * Half a quantisation step. A channel is one byte in the file and a
3308
- * `Float32Array` entry in a timeline, so the widest honest gap between the
3309
- * number written and the number posed is well under `1/510`.
3310
- */
3311
- const STEP = 1 / 510;
3312
- const off = (found: number, want: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > STEP;
3313
- const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
3314
-
3315
- // -- the subjects, read off the FILE ---------------------------------
3316
- const declared = new Map<string, string>();
3317
- for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
3318
- if (isObj(slot) && typeof slot.dark === 'string') declared.set(String(slot.name), slot.dark);
3319
- }
3320
- // `rgb2` joined in issue #730, and it is this rule's subject rather than a
3321
- // new one's because it is the same tint with the light alpha left out:
3322
- // `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
3323
- // with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
3324
- // does (measured on a forged file: `TypeError: null is not an object
3325
- // (evaluating 'dark.r = …')`). The one thing it does differently is the
3326
- // alpha it does NOT pose, which is `A45`'s question, not this one's.
3327
- const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
3328
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
3329
- for (const [animName, anim] of Object.entries(rawAnimations)) {
3330
- if (!isObj(anim) || !isObj(anim.slots)) continue;
3331
- for (const [slotName, timelines] of Object.entries(anim.slots)) {
3332
- if (!isObj(timelines)) continue;
3333
- for (const timeline of ['rgba2', 'rgb2'] as const) {
3334
- const keys = timelines[timeline];
3335
- if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
3336
- }
3337
- }
3338
- }
3339
- if (declared.size === 0 && keyed.length === 0) {
3340
- return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
3341
- }
3342
- stats.darkSlots = declared.size;
3343
- stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
3344
- stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
3345
-
3346
- // -- clause 1: the setup pose ----------------------------------------
3347
- for (const [name, hex] of declared) {
3348
- const want = readDarkHex(hex);
3349
- const loaded = data.findSlot(name)?.setupPose.darkColor ?? null;
3350
- // ⚠️ "The parser kept nothing" is asked FIRST, and the order is the
3351
- // finding rather than a style: an empty string is both unreadable as a
3352
- // colour and dropped outright, and only the second sentence is about
3353
- // what the loaded skeleton holds. Asked the other way round, `""` was
3354
- // reported as "not six hex digits" — true, and about the file, on the
3355
- // one input where the file is not what went wrong.
3356
- if (loaded === null) {
3357
- fail(
3358
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3359
- `slot "${name}" states dark ${JSON.stringify(hex)} and the loaded skeleton holds no dark colour for ` +
3360
- 'it at all — the slot reader takes `dark` through a truthiness test, so a falsy value is dropped ' +
3361
- 'in silence and the slot is tinted with one colour',
3362
- );
3363
- continue;
3364
- }
3365
- if (want === null) {
3366
- fail(
3367
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3368
- `slot "${name}" states dark ${JSON.stringify(hex)}, which is not six hex digits — the parser reads ` +
3369
- 'fixed two-character slices and stores whatever `parseInt` returns, so the loaded colour is ' +
3370
- `(${show([loaded.r, loaded.g, loaded.b])}) rather than a failure`,
3371
- );
3372
- continue;
3373
- }
3374
- const found: [number, number, number] = [loaded.r, loaded.g, loaded.b];
3375
- if (found.some((n, i) => off(n, want[i]))) {
3376
- fail(
3377
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3378
- `slot "${name}" states dark ${JSON.stringify(hex)} and the runtime loaded (${show(found)}), wanted ` +
3379
- `(${show(want)})`,
3380
- );
3381
- }
3382
- }
3383
-
3384
- // -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
3385
- //
3386
- // ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
3387
- // throw happens inside `state.apply`, which applies every timeline of the
3388
- // animation at once, so posing a second — correct — `rgba2` timeline in the
3389
- // same animation would take this assertion down with a `threw:` line
3390
- // instead of the two named failures it has already worked out.
3391
- const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
3392
- for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
3393
- const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
3394
- if (!declared.has(slotName)) {
3395
- fail(
3396
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3397
- `${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
3398
- `it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
3399
- `"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
3400
- );
3401
- continue;
3402
- }
3403
- if (cannotPose.has(animName)) continue;
3404
- const animation = data.findAnimation(animName);
3405
- if (!animation) {
3406
- fail('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', `${where}: the loaded skeleton has no animation "${animName}"`);
3407
- continue;
3408
- }
3409
- for (const rawKey of keys) {
3410
- if (!isObj(rawKey)) continue;
3411
- const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
3412
- const wantLight = typeof rawKey.light === 'string' ? readLightHex(rawKey.light) : null;
3413
- const wantDark = typeof rawKey.dark === 'string' ? readDarkHex(rawKey.dark) : null;
3414
- // ⚠️ Posed BEFORE the key's own spelling is judged, so that a key whose
3415
- // hex cannot be read is still reported with the colour the runtime
3416
- // actually holds. `Color.setFromString` slices fixed offsets and stores
3417
- // whatever `parseInt` gives back, so the value found is the product
3418
- // here — "not six hex digits" alone would be the value REQUIRED twice
3419
- // over and the found value nowhere.
3420
- const skeleton = new Skeleton(data);
3421
- const state = new AnimationState(new AnimationStateData(data));
3422
- state.setAnimation(0, animName, false);
3423
- skeleton.setupPose();
3424
- skeleton.update(0);
3425
- skeleton.updateWorldTransform(Physics.reset);
3426
- // At the key as the runtime stores it — see `atStoredKey` (#771).
3427
- state.update(atStoredKey(time));
3428
- state.apply(skeleton);
3429
- skeleton.update(atStoredKey(time));
3430
- skeleton.updateWorldTransform(Physics.update);
3431
- const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
3432
- const light = posed?.color;
3433
- const dark = posed?.darkColor ?? null;
3434
- if (!light || dark === null) {
3435
- fail(
3436
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3437
- `${where} (t=${time}): the posed skeleton has no ${light ? 'dark colour' : 'slot'} to read`,
3438
- );
3439
- continue;
3440
- }
3441
- const foundLight: [number, number, number, number] = [light.r, light.g, light.b, light.a];
3442
- const foundDark: [number, number, number] = [dark.r, dark.g, dark.b];
3443
- if (wantLight === null || wantDark === null) {
3444
- fail(
3445
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3446
- `${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
3447
- `${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
3448
- `runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
3449
- );
3450
- continue;
3451
- }
3452
- // An `rgb2` key's light colour is three channels, and the posed alpha
3453
- // is not its to state — `RGB2Timeline` leaves it where it was — so the
3454
- // comparison is over the channels the timeline writes. For `rgba2`
3455
- // that is all four, and the line reads as it always did.
3456
- const lightChannels = timeline === 'rgba2' ? 4 : 3;
3457
- if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
3458
- fail(
3459
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3460
- `${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
3461
- `${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
3462
- );
3463
- }
3464
- if (foundDark.some((n, i) => off(n, wantDark[i]))) {
3465
- fail(
3466
- 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3467
- `${where} (t=${time}): dark posed (${show(foundDark)}), the key states ${JSON.stringify(rawKey.dark)} ` +
3468
- `= (${show(wantDark)})`,
3469
- );
3470
- }
3471
- }
3472
- }
3473
- });
3180
+ //
3181
+ // The body is `./assertions/bodies/a43.ts` since issue #1025 (cut 4c-3);
3182
+ // what it reads off the skeleton JSON and the loaded skeleton is
3183
+ // `spineTwoColourFacts`.
3184
+ check('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', () => a43TwoColorTintLoadsAndPosesAsWritten(verdicts, spineTwoColourFacts(raw, data)));
3474
3185
 
3475
3186
  // --- A45: the separable colour timelines own their channels ------------
3476
3187
  //
@@ -3530,7 +3241,7 @@ export function validate(input: ValidateInput): ValidateReport {
3530
3241
  // every key at sampled times — mid-frame, so a float32 key time cannot land
3531
3242
  // a sample on a frame boundary — and compares the region the slot shows
3532
3243
  // against the one the file's own statement gives: the frame the key's mode,
3533
- // index and delay give (`frameOf` below), and the frame names
3244
+ // index and delay give (`frameOf` in the body), and the frame names
3534
3245
  // `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
3535
3246
  // read off the loaded timeline, so the check is not the runtime agreeing
3536
3247
  // with itself.
@@ -3539,245 +3250,11 @@ export function validate(input: ValidateInput): ValidateReport {
3539
3250
  // `applyToSlot` returns there, and a series that is hidden is not a series
3540
3251
  // showing the wrong frame. A timeline with no comparable sample at all is
3541
3252
  // counted in `stats.sequenceSamplesUnshown` rather than failed.
3542
- check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => {
3543
- const NAME = 'A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES';
3544
- type Series = { where: string; lookups: string[] | null; count: number; setup: number };
3545
- /** The loaded attachment -> what the FILE says its series is. */
3546
- const series = new Map<object, Series>();
3547
- /** `skin\0slot\0placeholder` -> the loaded attachment, for the timelines to find. */
3548
- const byJoin = new Map<string, object>();
3549
- /** Attachments whose block was already refused above — their timelines say nothing more. */
3550
- const refused = new Set<object>();
3551
- const whole = (v: unknown, fallback: number): number | null =>
3552
- v === undefined ? fallback : typeof v === 'number' && Number.isInteger(v) ? v : null;
3553
- let blocks = 0;
3554
- for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
3555
- if (!isObj(skin) || !isObj(skin.attachments)) continue;
3556
- const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
3557
- const loadedSkin = data.findSkin(skinName);
3558
- for (const [slotName, entries] of Object.entries(skin.attachments)) {
3559
- if (!isObj(entries)) continue;
3560
- const slotIndex = data.findSlot(slotName)?.index;
3561
- for (const [placeholder, entry] of Object.entries(entries)) {
3562
- if (!isObj(entry)) continue;
3563
- const type = entry.type === undefined ? 'region' : entry.type;
3564
- if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
3565
- const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
3566
- const where = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(placeholder)}`;
3567
- if (loaded !== null) byJoin.set(`${skinName}\u0000${slotName}\u0000${placeholder}`, loaded);
3568
- if (entry.sequence === undefined || entry.sequence === null) continue;
3569
- blocks++;
3570
- const seq = entry.sequence;
3571
- const name = typeof entry.name === 'string' ? entry.name : placeholder;
3572
- const path = typeof entry.path === 'string' ? entry.path : name;
3573
- const count = isObj(seq) ? whole(seq.count, 0) : null;
3574
- const setup = isObj(seq) ? whole(seq.setup, 0) : null;
3575
- if ((count === null || setup === null || count < 1) && loaded !== null) refused.add(loaded);
3576
- if (count === null || setup === null || count < 1) {
3577
- fail(
3578
- NAME,
3579
- `${where}: the sequence states ${JSON.stringify(seq)}` +
3580
- (isObj(seq) && seq.count === undefined
3581
- ? ' and no "count" — `readSequence` reads 0 (`SkeletonJson.js:644`), so the attachment loads holding no region and draws nothing'
3582
- : ' — "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'),
3583
- );
3584
- continue;
3585
- }
3586
- if ((setup < 0 || setup >= count) && loaded !== null) refused.add(loaded);
3587
- if (setup < 0 || setup >= count) {
3588
- fail(
3589
- NAME,
3590
- `${where}: the sequence's setup frame is ${setup} of a ${count}-frame series (frames 0 to ${count - 1}); ` +
3591
- '`Sequence.resolveIndex` clamps it, so the setup pose shows ' +
3592
- `${setup < 0 ? 'no frame at all' : `frame ${count - 1}, which the file does not name`}`,
3593
- );
3594
- continue;
3595
- }
3596
- if (loaded === null) continue; // A00/A08 own an attachment that did not load
3597
- series.set(loaded, { where, lookups: attachmentRegionLookups(seq, path), count, setup });
3598
- }
3599
- }
3600
- }
3601
-
3602
- /**
3603
- * The frame the file's statement gives for one key at one elapsed time —
3604
- * A46's prediction, not a call into the runtime. What holds it is the
3605
- * comparison it feeds: every sample sets it against the frame the posed
3606
- * slot shows, so a reading that disagreed with the runtime would fail A46
3607
- * on a correct file. A46 passing with samples posed on the selftest's
3608
- * series probes is that measurement for the modes they key; `M73` is its
3609
- * red half.
3610
- */
3611
- const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
3612
- if (mode === 'hold') return index;
3613
- let i = index + Math.trunc(elapsed / delay + 0.00001);
3614
- const n = count * 2 - 2;
3615
- switch (mode) {
3616
- case 'once':
3617
- return Math.min(count - 1, i);
3618
- case 'loop':
3619
- return i % count;
3620
- case 'pingpong':
3621
- i = n === 0 ? 0 : i % n;
3622
- return i >= count ? n - i : i;
3623
- case 'onceReverse':
3624
- return Math.max(count - 1 - i, 0);
3625
- case 'loopReverse':
3626
- return count - 1 - (i % count);
3627
- default: // pingpongReverse
3628
- i = n === 0 ? 0 : (i + count - 1) % n;
3629
- return i >= count ? n - i : i;
3630
- }
3631
- };
3632
-
3633
- let timelines = 0;
3634
- let compared = 0;
3635
- let unshown = 0;
3636
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
3637
- for (const [animName, anim] of Object.entries(rawAnimations)) {
3638
- if (!isObj(anim) || !isObj(anim.attachments)) continue;
3639
- for (const [skinName, perSkin] of Object.entries(anim.attachments)) {
3640
- if (!isObj(perSkin)) continue;
3641
- for (const [slotName, perSlot] of Object.entries(perSkin)) {
3642
- if (!isObj(perSlot)) continue;
3643
- for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
3644
- if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
3645
- const keys = perAttachment.sequence as unknown[];
3646
- if (keys.length === 0) continue; // `readAnimation` skips it; A34 owns an empty timeline
3647
- timelines++;
3648
- const where = `animation ${JSON.stringify(animName)} ${skinName}/${slotName}/${placeholder} sequence`;
3649
- const keyed = byJoin.get(`${skinName}\u0000${slotName}\u0000${placeholder}`);
3650
- if (keyed === undefined) continue; // the parser throws on a missing target: A00's
3651
- if (refused.has(keyed)) continue; // its block is already named above
3652
- const own = series.get(keyed);
3653
- if (own === undefined) {
3654
- fail(
3655
- NAME,
3656
- `${where}: the timeline steps an attachment that carries no "sequence" block. The parser gives it a ` +
3657
- 'series of ONE region (`readSequence(null)` is `new Sequence(1, false)`), so every mode shows that ' +
3658
- 'region at every time — measured: a "loop" key on a plain region showed it throughout',
3659
- );
3660
- continue;
3661
- }
3662
- // -- the keys, as the file states them --------------------------
3663
- type Key = { time: number; mode: string; index: number; delay: number };
3664
- const read: Key[] = [];
3665
- let carried = 0;
3666
- let malformed = false;
3667
- keys.forEach((rawKey, k) => {
3668
- const key = isObj(rawKey) ? rawKey : {};
3669
- const at = `${where} key ${k}`;
3670
- const time = typeof key.time === 'number' ? key.time : 0;
3671
- const mode = key.mode === undefined ? 'hold' : key.mode;
3672
- const index = key.index === undefined ? 0 : key.index;
3673
- if (key.delay !== undefined) carried = typeof key.delay === 'number' ? key.delay : Number.NaN;
3674
- if (typeof mode !== 'string' || !(SEQUENCE_MODES as readonly string[]).includes(mode)) {
3675
- malformed = true;
3676
- fail(
3677
- NAME,
3678
- `${at} (t=${time}): mode ${JSON.stringify(key.mode)} is not one of the ${SEQUENCE_MODES.length} the ` +
3679
- `format has (${SEQUENCE_MODES.join(', ')}); the parser reads \`SequenceMode[mode]\`, which is ` +
3680
- 'undefined, stores mode bits 0, and the key plays as "hold"',
3681
- );
3682
- return;
3683
- }
3684
- if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= own.count) {
3685
- malformed = true;
3686
- fail(
3687
- NAME,
3688
- `${at} (t=${time}): index ${JSON.stringify(key.index)} is not a frame of the ${own.count}-frame series ` +
3689
- `(0 to ${own.count - 1}); the runtime stores \`index << 4\`, truncating a fraction, and ` +
3690
- '`Sequence.resolveIndex` clamps a frame past the end to the last one',
3691
- );
3692
- return;
3693
- }
3694
- if (mode !== 'hold' && !(carried > 0)) {
3695
- malformed = true;
3696
- fail(
3697
- NAME,
3698
- `${at} (t=${time}): "${mode}" at a delay of ${String(carried)}${key.delay === undefined ? ' (carried from the key before, 0 on the first)' : ''} — ` +
3699
- 'the frame advances by `(time - keyTime) / delay`, and at 0 that is Infinity, `Infinity | 0` is 0, ' +
3700
- 'and the key shows its first frame throughout: "hold" spelt as another mode',
3701
- );
3702
- return;
3703
- }
3704
- read.push({ time, mode, index, delay: carried });
3705
- });
3706
- if (malformed) continue;
3707
-
3708
- // -- the pose, sampled ------------------------------------------
3709
- const animation = data.findAnimation(animName);
3710
- const slotIndex = data.findSlot(slotName)?.index;
3711
- if (animation === null || slotIndex === undefined) continue; // A00's
3712
- /** `key` is the index into `read`, or -1 before the first key (the setup frame). */
3713
- const samples: Array<{ time: number; key: number; steps: number }> = [];
3714
- const end = animation.duration;
3715
- if (read[0].time > 0) samples.push({ time: read[0].time / 2, key: -1, steps: 0 });
3716
- read.forEach((key, k) => {
3717
- const until = k + 1 < read.length ? read[k + 1].time : end;
3718
- if (key.mode === 'hold') {
3719
- samples.push({ time: key.time, key: k, steps: 0 });
3720
- return;
3721
- }
3722
- // Mid-frame, and enough steps to wrap every mode at least once.
3723
- for (let step = 0; step < own.count * 2 + 2; step++) {
3724
- const time = key.time + (step + 0.5) * key.delay;
3725
- if (time >= until || time > end) break;
3726
- samples.push({ time, key: k, steps: step + 0.5 });
3727
- }
3728
- });
3729
- let shownHere = 0;
3730
- for (const sample of samples) {
3731
- const skeleton = new Skeleton(data);
3732
- const state = new AnimationState(new AnimationStateData(data));
3733
- state.setAnimation(0, animName, false);
3734
- skeleton.setupPose();
3735
- skeleton.update(0);
3736
- skeleton.updateWorldTransform(Physics.reset);
3737
- // A `hold` sample is AT its key, so it is posed at the key as
3738
- // the runtime stores it (`atStoredKey`); a mid-frame sample is
3739
- // half a delay from any key and is posed where it is.
3740
- state.update(sample.key >= 0 && sample.steps === 0 ? atStoredKey(sample.time) : sample.time);
3741
- state.apply(skeleton);
3742
- const pose = skeleton.slots[slotIndex].appliedPose;
3743
- const shown = pose.attachment;
3744
- // The slot must show the keyed attachment or one playing its
3745
- // timelines — the only case `applyToSlot` writes.
3746
- if (shown === null || (shown !== keyed && shown.timelineAttachment !== keyed)) continue;
3747
- const drawn = series.get(shown);
3748
- if (drawn === undefined || drawn.lookups === null) continue;
3749
- shownHere++;
3750
- compared++;
3751
- // The frame count the runtime folds by is the SHOWN attachment's:
3752
- // a link with a series of its own steps it by its source's keys.
3753
- const key = sample.key < 0 ? null : read[sample.key];
3754
- const want = key === null ? drawn.setup : frameOf(key.mode, key.index, sample.time - key.time, key.delay, drawn.count);
3755
- const sequence = (shown as RegionAttachment | MeshAttachment).sequence;
3756
- const region = (sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null;
3757
- if (region !== drawn.lookups[want]) {
3758
- fail(
3759
- NAME,
3760
- `${where} (t=${Number(sample.time.toFixed(4))}): the slot shows region ${JSON.stringify(region)}, and ` +
3761
- `the file states frame ${want} of ${drawn.count} — ${JSON.stringify(drawn.lookups[want])} — ` +
3762
- (key === null
3763
- ? 'the setup frame, before the first key'
3764
- : `key ${sample.key} plays "${key.mode}" from frame ${key.index} every ${key.delay}s, ` +
3765
- `${sample.steps} delay(s) in`),
3766
- );
3767
- break;
3768
- }
3769
- }
3770
- if (shownHere === 0) unshown++;
3771
- }
3772
- }
3773
- }
3774
- }
3775
- if (blocks === 0 && timelines === 0) return skip(NAME, SKIP_NO_SEQUENCE);
3776
- stats.sequenceBlocks = blocks;
3777
- stats.sequenceTimelines = timelines;
3778
- stats.sequenceSamples = compared;
3779
- if (unshown > 0) stats.sequenceSamplesUnshown = unshown;
3780
- });
3253
+ //
3254
+ // The body is `./assertions/bodies/a46.ts` since issue #1025 (cut 4c-3);
3255
+ // what it reads off the skeleton JSON and the loaded skeleton is
3256
+ // `spineSequenceFacts`, which answers an attachment by its address.
3257
+ check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => a46SequenceAttachmentsShowTheFrameTheFileStates(verdicts, spineSequenceFacts(raw, data)));
3781
3258
  }
3782
3259
 
3783
3260
  // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
@@ -3853,63 +3330,7 @@ export function validate(input: ValidateInput): ValidateReport {
3853
3330
  //
3854
3331
  // The axis bone itself must carry no keys at all: `invariants.axisBone` names
3855
3332
  // a per-cut SETUP value, and animating it swings the whole formation.
3856
- check('A24_AXIS_SPACE_STROKE', () => {
3857
- const rig = input.rig;
3858
- if (!rig) return skip('A24_AXIS_SPACE_STROKE', 'no rig info (validating a bare directory)');
3859
- if (!rig.axisBone) {
3860
- return skip(
3861
- 'A24_AXIS_SPACE_STROKE',
3862
- `the rig "${rig.archetype}" declares no axis bone, so there is no axis space for a stroke to leave`,
3863
- );
3864
- }
3865
- const subtree = new Set(rig.axisSubtree);
3866
- const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
3867
- // The stroke is the subject and its keys are where it lives (#580). A rig
3868
- // that names an axis bone and then keys neither it nor anything under it has
3869
- // no stroke for this rule to find out of axis space, and a loop over nothing
3870
- // used to report that as held.
3871
- let keyed = 0;
3872
- for (const [animName, anim] of Object.entries(anims)) {
3873
- if (!isObj(anim) || !isObj(anim.bones)) continue;
3874
- for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
3875
- if (boneName === rig.axisBone) {
3876
- keyed++;
3877
- fail(
3878
- 'A24_AXIS_SPACE_STROKE',
3879
- `"${animName}" keys the axis bone "${boneName}"; the axis angle is a per-cut SETUP value, not animation`,
3880
- );
3881
- continue;
3882
- }
3883
- if (subtree.has(boneName)) keyed++;
3884
- if (!subtree.has(boneName) || !isObj(timelines)) continue;
3885
- if ('translatey' in timelines) {
3886
- fail(
3887
- 'A24_AXIS_SPACE_STROKE',
3888
- `"${animName}" gives "${boneName}" a translatey timeline; a bone under "${rig.axisBone}" moves along the axis only`,
3889
- );
3890
- }
3891
- const keys = (timelines as Json).translate;
3892
- if (!Array.isArray(keys)) continue;
3893
- for (const key of keys) {
3894
- if (!isObj(key)) continue;
3895
- const y = Number(key.y ?? 0);
3896
- if (Number.isFinite(y) && Math.abs(y) > 1e-6) {
3897
- fail(
3898
- 'A24_AXIS_SPACE_STROKE',
3899
- `"${animName}" keys "${boneName}" translate y=${y} at t=${String(key.time ?? 0)}; the axis bone carries the direction, so keys are translateX only`,
3900
- );
3901
- }
3902
- }
3903
- }
3904
- }
3905
- if (keyed === 0) {
3906
- return skip(
3907
- 'A24_AXIS_SPACE_STROKE',
3908
- `no animation keys the axis bone "${rig.axisBone}" or any of the ${rig.axisSubtree.length} bone(s) under ` +
3909
- 'it, so this rig has no stroke to hold in axis space',
3910
- );
3911
- }
3912
- });
3333
+ check('A24_AXIS_SPACE_STROKE', () => a24AxisSpaceStroke(verdicts, rawBoneTimelines(raw), input));
3913
3334
 
3914
3335
  // --- A25: parentage that must never happen -------------------------------
3915
3336
  //
@@ -3920,36 +3341,7 @@ export function validate(input: ValidateInput): ValidateReport {
3920
3341
  // the reason it is tempting, because the wrong parentage still loads and still
3921
3342
  // animates — it just lies. That is exactly the class of invariant that belongs
3922
3343
  // in a machine guard rather than in prose.
3923
- check('A25_DETACHED_BONE_PARENTAGE', () => {
3924
- const rig = input.rig;
3925
- if (!rig) return skip('A25_DETACHED_BONE_PARENTAGE', 'no rig info (validating a bare directory)');
3926
- if (!rig.detached.length) {
3927
- return skip('A25_DETACHED_BONE_PARENTAGE', `the rig "${rig.archetype}" declares no forbidden parentage`);
3928
- }
3929
- const parentOf = new Map<string, string | null>();
3930
- for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
3931
- if (isObj(bone) && typeof bone.name === 'string') {
3932
- parentOf.set(bone.name, typeof bone.parent === 'string' ? bone.parent : null);
3933
- }
3934
- }
3935
- for (const [child, forbidden] of rig.detached) {
3936
- if (!parentOf.has(child)) {
3937
- fail('A25_DETACHED_BONE_PARENTAGE', `the rig declares "${child}" detached from "${forbidden}" but has no such bone`);
3938
- continue;
3939
- }
3940
- const seen = new Set<string>();
3941
- for (let cursor = parentOf.get(child) ?? null; cursor; cursor = parentOf.get(cursor) ?? null) {
3942
- if (seen.has(cursor)) break; // a cycle; the loader would have thrown first
3943
- seen.add(cursor);
3944
- if (cursor !== forbidden) continue;
3945
- fail(
3946
- 'A25_DETACHED_BONE_PARENTAGE',
3947
- `"${child}" is a descendant of "${forbidden}"; it must not be dragged by that bone's motion`,
3948
- );
3949
- break;
3950
- }
3951
- }
3952
- });
3344
+ check('A25_DETACHED_BONE_PARENTAGE', () => a25DetachedBoneParentage(verdicts, rawSkeletonRoster(raw), input));
3953
3345
 
3954
3346
  // --- A26: the slots array IS the rig's slot table ------------------------
3955
3347
  //
@@ -3969,56 +3361,7 @@ export function validate(input: ValidateInput): ValidateReport {
3969
3361
  // fills it, so a shorter array is a loss and is named as one here. A slot
3970
3362
  // missing from the array moves every slot below it up one index, which is
3971
3363
  // what a `drawOrder` key's offsets are counted against.
3972
- check('A26_SLOT_DRAW_ORDER', () => {
3973
- if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
3974
- const order = input.rig.slotOrder;
3975
- if (!order) {
3976
- return skip(
3977
- 'A26_SLOT_DRAW_ORDER',
3978
- `the rig "${input.rig.archetype}" declares no canonical slot order, so the emitted order has nothing to disagree with`,
3979
- );
3980
- }
3981
- const names = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [])
3982
- .filter(isObj)
3983
- .map((s) => String(s.name));
3984
- // ⛔ **No empty-subject SKIP here, and #575 is the whole reason** (#580). The
3985
- // empty sequence is a subsequence of every table, so before #575 a skeleton
3986
- // with no slot reported its draw order held and this rule wanted the same
3987
- // guard its neighbours got. It no longer does: the completeness clause below
3988
- // reads zero emitted slots against a declared table as EVERY slot lost,
3989
- // which is the maximal case of exactly the defect #575 filed — so the honest
3990
- // verdict is a FAIL naming them, and a SKIP here would suppress it. Measured:
3991
- // with the guard in place a rig declaring one slot beside an artifact
3992
- // carrying none reported SKIP; with it gone, `"block" is declared and not
3993
- // emitted`. A26 therefore has no vacuous pass left to convert, the way
3994
- // `A07_ATLAS_TEXT_SHAPE` has none.
3995
- let at = 0;
3996
- for (const name of names) {
3997
- const found = order.indexOf(name, at);
3998
- if (found < 0) {
3999
- const known = order.indexOf(name);
4000
- fail(
4001
- 'A26_SLOT_DRAW_ORDER',
4002
- known < 0
4003
- ? `slot "${name}" is not in the archetype's slot table`
4004
- : `slot "${name}" is drawn out of order (table position ${known}, after a slot at ${at})`,
4005
- );
4006
- return;
4007
- }
4008
- at = found + 1;
4009
- }
4010
- const emitted = new Set(names);
4011
- const missing = order.filter((name) => !emitted.has(name));
4012
- if (missing.length > 0) {
4013
- fail(
4014
- 'A26_SLOT_DRAW_ORDER',
4015
- `the rig "${input.rig.archetype}" declares ${order.length} slot(s) and the skeleton has ${names.length}: ` +
4016
- `${missing.map((name) => `"${name}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} declared and ` +
4017
- 'not emitted. A slot nothing fills is emitted with no setup attachment, not dropped — dropping one ' +
4018
- 'moves every slot below it up one index',
4019
- );
4020
- }
4021
- });
3364
+ check('A26_SLOT_DRAW_ORDER', () => a26SlotDrawOrder(verdicts, rawSkeletonRoster(raw), input));
4022
3365
 
4023
3366
  // --- A27: region name == the PNG's basename ------------------------------
4024
3367
  //
@@ -4059,25 +3402,7 @@ export function validate(input: ValidateInput): ValidateReport {
4059
3402
  // get projected onto the axis rather than read as axis coordinates.
4060
3403
  // Ignoring them would let a rig pass while a recoil key closed the last few
4061
3404
  // pixels of the gap.
4062
- check('A29_STROKE_WITHIN_CONTACT_DEPTH', () => {
4063
- const rig = input.rig;
4064
- if (!rig) return skip('A29_STROKE_WITHIN_CONTACT_DEPTH', 'no rig info (validating a bare directory)');
4065
- if (!rig.contactDepth) {
4066
- return skip(
4067
- 'A29_STROKE_WITHIN_CONTACT_DEPTH',
4068
- 'the manifest declares no `stroke.contact_depth`, so this cut has no measured contact ceiling to hold the stroke to',
4069
- );
4070
- }
4071
- const deep = deepestInwardAdvance(raw, rig);
4072
- stats.contactDepth = rig.contactDepth;
4073
- stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
4074
- if (deep.total > rig.contactDepth + 1e-6) {
4075
- fail(
4076
- 'A29_STROKE_WITHIN_CONTACT_DEPTH',
4077
- `${deep.describe()} but the masses meet at ${rig.contactDepth}px — the two plates would interpenetrate`,
4078
- );
4079
- }
4080
- });
3405
+ check('A29_STROKE_WITHIN_CONTACT_DEPTH', () => a29StrokeWithinContactDepth(verdicts, rawBoneTimelines(raw), input));
4081
3406
 
4082
3407
  // --- A30: inward travel stops where the drawn cover runs out -------------
4083
3408
  //
@@ -4100,42 +3425,7 @@ export function validate(input: ValidateInput): ValidateReport {
4100
3425
  // validates. Rather than assert a number that no longer means anything, refuse
4101
3426
  // the deformation. A cut that wants squash under a declared ceiling has to
4102
3427
  // re-measure containment for the scaled contour and say so.
4103
- check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => {
4104
- const rig = input.rig;
4105
- if (!rig) return skip('A30_STROKE_WITHIN_CAP_CONTAINMENT', 'no rig info (validating a bare directory)');
4106
- if (!rig.capContainmentCeiling) {
4107
- return skip(
4108
- 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
4109
- 'the manifest declares no `stroke.cap_containment_ceiling`, so this cut has no measured containment ceiling',
4110
- );
4111
- }
4112
- const deep = deepestInwardAdvance(raw, rig);
4113
- stats.capCeiling = rig.capContainmentCeiling;
4114
- stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
4115
- if (deep.total > rig.capContainmentCeiling + 1e-6) {
4116
- fail(
4117
- 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
4118
- `${deep.describe()} but the leading contour leaves the occluder's opaque footprint at ` +
4119
- `${rig.capContainmentCeiling}px — the part would be drawn where it should be covered`,
4120
- );
4121
- }
4122
- const subtree = new Set(rig.axisSubtree);
4123
- const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
4124
- for (const [animName, anim] of Object.entries(anims)) {
4125
- if (!isObj(anim) || !isObj(anim.bones)) continue;
4126
- for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
4127
- if (!subtree.has(boneName) || !isObj(timelines)) continue;
4128
- for (const name of Object.keys(timelines)) {
4129
- if (name !== 'scale' && name !== 'scalex' && name !== 'scaley') continue;
4130
- fail(
4131
- 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
4132
- `"${animName}" gives "${boneName}" a ${name} timeline while a cap-containment ceiling is declared; ` +
4133
- 'the ceiling was measured on the undeformed contour, so a scaled plate is outside its evidence',
4134
- );
4135
- }
4136
- }
4137
- }
4138
- });
3428
+ check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
4139
3429
 
4140
3430
  // --- A18: determinism ----------------------------------------------------
4141
3431
  check('A18_DETERMINISTIC_EMIT', () => {
@@ -4211,63 +3501,6 @@ export function validate(input: ValidateInput): ValidateReport {
4211
3501
  return { failures, passed, skipped, profileSkipped, profile, stats };
4212
3502
  }
4213
3503
 
4214
- /**
4215
- * Deepest inward advance the animation data asks for, in axis pixels.
4216
- *
4217
- * Shared by A29 and A30 because they bound the SAME quantity against two
4218
- * different measured facts. Two things spend the same clearance and so are added:
4219
- *
4220
- * * the stroke — a translateX on a bone in the axis subtree. A24 guarantees
4221
- * there is no hidden screen-space component to miss.
4222
- * * the mass bone's own inward keys. It typically hangs outside the axis
4223
- * subtree, so its keys are screen-space by design and get PROJECTED onto the
4224
- * axis rather than read as axis coordinates. Ignoring them would let a rig
4225
- * pass while a recoil key closed the last few pixels.
4226
- */
4227
- function deepestInwardAdvance(
4228
- raw: Json | null,
4229
- rig: NonNullable<ValidateInput['rig']>,
4230
- ): { total: number; describe: () => string } {
4231
- const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
4232
- const subtree = new Set(rig.axisSubtree);
4233
- let strokeMax = 0;
4234
- let strokeWhere = '';
4235
- let massMax = 0;
4236
- let massWhere = '';
4237
- for (const [animName, anim] of Object.entries(anims)) {
4238
- if (!isObj(anim) || !isObj(anim.bones)) continue;
4239
- for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
4240
- if (!isObj(timelines)) continue;
4241
- const keys = (timelines as Json).translate;
4242
- if (!Array.isArray(keys)) continue;
4243
- for (const key of keys) {
4244
- if (!isObj(key)) continue;
4245
- if (subtree.has(boneName)) {
4246
- // +x is inward along the axis; a retracted key is negative and spends
4247
- // no clearance, so only the inward extreme matters.
4248
- const x = Number(key.x ?? 0);
4249
- if (Number.isFinite(x) && x > strokeMax) {
4250
- strokeMax = x;
4251
- strokeWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
4252
- }
4253
- } else if (boneName === rig.massBone && rig.inwardUnit) {
4254
- const inward = Number(key.x ?? 0) * rig.inwardUnit[0] + Number(key.y ?? 0) * rig.inwardUnit[1];
4255
- if (Number.isFinite(inward) && inward > massMax) {
4256
- massMax = inward;
4257
- massWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
4258
- }
4259
- }
4260
- }
4261
- }
4262
- }
4263
- return {
4264
- total: strokeMax + massMax,
4265
- describe: () =>
4266
- `deepest inward advance is ${(strokeMax + massMax).toFixed(3)}px (stroke ${strokeMax.toFixed(3)} at ${strokeWhere}` +
4267
- `${massMax > 0 ? ` + mass ${massMax.toFixed(3)} at ${massWhere}` : ''})`,
4268
- };
4269
- }
4270
-
4271
3504
  /**
4272
3505
  * Decode a weighted mesh's `vertices` run into per-vertex (boneIndex, weight).
4273
3506
  *