rig-c 0.0.0-stage → 2.20.4

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 (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
@@ -0,0 +1,3875 @@
1
+ /**
2
+ * rigc validate — the other half of the tool.
3
+ *
4
+ * The parser is forgiving, and that is the danger: there are at least six ways
5
+ * to write a wrong skeleton that loads with no error at all. So this stage has
6
+ * two layers:
7
+ *
8
+ * A. Round-trip through the REAL spine-core. If TextureAtlas or SkeletonJson
9
+ * throws, the artifact is dead on arrival — those are the two failures the
10
+ * parser does report.
11
+ * B. Assertions we make ourselves, because the parser will not. Every silent
12
+ * failure becomes one named machine check here.
13
+ *
14
+ * A failure is a named assertion, and a named assertion is a nonzero exit.
15
+ */
16
+ import { dirname, resolve } from 'node:path';
17
+ import {
18
+ type Animation,
19
+ AnimationState,
20
+ AnimationStateData,
21
+ AtlasAttachmentLoader,
22
+ BoundingBoxAttachment,
23
+ ClippingAttachment,
24
+ type ConstraintTimeline,
25
+ type CurveTimeline,
26
+ DeformTimeline,
27
+ IkConstraintData,
28
+ Inherit,
29
+ isBoneTimeline,
30
+ isConstraintTimeline,
31
+ isSlotTimeline,
32
+ MeshAttachment,
33
+ MixFrom,
34
+ PathAttachment,
35
+ PathConstraintData,
36
+ PathConstraintMixTimeline,
37
+ Physics,
38
+ PhysicsConstraintData,
39
+ PhysicsConstraintPose,
40
+ PhysicsConstraintResetTimeline,
41
+ PhysicsConstraintTimeline,
42
+ Property,
43
+ RegionAttachment,
44
+ SequenceTimeline as RuntimeSequenceTimeline,
45
+ Skeleton,
46
+ SkeletonJson,
47
+ SliderData,
48
+ TextureAtlas,
49
+ type TextureAtlasRegion,
50
+ type Timeline,
51
+ ToRotate,
52
+ ToScaleX,
53
+ ToScaleY,
54
+ ToShearY,
55
+ ToX,
56
+ ToY,
57
+ TransformConstraintData,
58
+ } from '@esotericsoftware/spine-core';
59
+ // ⚠️ `src/` reaches outside itself for exactly two modules and this is one of
60
+ // them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
61
+ // A19 needs the DECODED page, not its header, to measure one region's own
62
+ // rectangle on a shared page.
63
+ import { BEZIER_POINTS, curveStorage, surveyDeformKeys } from './deformmeasure.ts';
64
+ import {
65
+ LEGACY_BONE_INHERIT_KEY,
66
+ spineGeneration,
67
+ TOPLEVEL_CONSTRAINT_ARRAYS,
68
+ type SpineGeneration,
69
+ } from './generation.ts';
70
+ import { posedNumbersOf } from './render.ts';
71
+ import { MODEL_DOCUMENT_SPEC } from './model.ts';
72
+ import { BONE_INHERIT_KNOWN } from './rig.ts';
73
+ import {
74
+ CHANNELS_BY_KIND,
75
+ physicsRuleFor,
76
+ SLOT_COLOR_CHANNELS,
77
+ walkTimelines,
78
+ } from './timelines.ts';
79
+ import type { RigInfo } from './types.ts';
80
+ import { ASSERTION_KIND, kindRunsUnder, type AssertionProfile } from './assertions/kinds.ts';
81
+ import { verdictHarness, type Failure } from './assertions/harness.ts';
82
+ import { isObj, type Json } from './assertions/values.ts';
83
+ import type { SkinEntryFacts } from './assertions/facts/skin_entries.ts';
84
+ import type { AtlasPageFacts } from './assertions/facts/atlas_pages.ts';
85
+ import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_colour.ts';
86
+ import { a03RegionWidthHeightFinite } from './assertions/bodies/a03.ts';
87
+ import { a11NoClippingAttachments } from './assertions/bodies/a11.ts';
88
+ import { a17AtlasPageFilesExist } from './assertions/bodies/a17.ts';
89
+ import { a18DeterministicEmit } from './assertions/bodies/a18.ts';
90
+ import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from './assertions/bodies/a45.ts';
91
+ import { attachmentRegionLookups, type AttachmentRegionJoin } from './assertions/region_lookups.ts';
92
+ import { attachmentRegionJoins } from './region_joins.ts';
93
+ import type { AnimatedBoneFacts } from './assertions/facts/animated_bones.ts';
94
+ import type { RegionJoinFacts } from './assertions/facts/region_joins.ts';
95
+ import type { StageFacts } from './assertions/facts/stage.ts';
96
+ import type { AtlasRegionFacts } from './assertions/facts/atlas_regions.ts';
97
+ import type { SkinMemberFacts } from './assertions/facts/skin_members.ts';
98
+ import { a08RegionNamesMatchAttachments } from './assertions/bodies/a08.ts';
99
+ import { a13MeshBudget } from './assertions/bodies/a13.ts';
100
+ import { a14NoFullFrameMesh } from './assertions/bodies/a14.ts';
101
+ import { a15IdleNoMeshBoneKeys } from './assertions/bodies/a15.ts';
102
+ import { a22MeshUvsInUnitRange } from './assertions/bodies/a22.ts';
103
+ import { a38SkinMembersAreSkinRequired } from './assertions/bodies/a38.ts';
104
+ import { a06AtlasPageSizeMatchesPng } from './assertions/bodies/a06.ts';
105
+ import { a19OverlayPngsHaveAlpha } from './assertions/bodies/a19.ts';
106
+ import { a49PackedFootprintsDoNotOverlap } from './assertions/bodies/a49.ts';
107
+ import { a50StageBoxIsTheStage } from './assertions/bodies/a50.ts';
108
+ import { SKIP_NO_STAGE_BOX, type StageBoxFacts } from './assertions/facts/stage_box.ts';
109
+ import { a27RegionNameMatchesPageFilename } from './assertions/bodies/a27.ts';
110
+ import type { MeshEntry as MeshAttachmentEntry, MeshFacts } from './assertions/facts/mesh_attachments.ts';
111
+ import type { ClipEnd, PolygonEntry, PolygonFacts } from './assertions/facts/vertex_polygons.ts';
112
+ import type { LinkEntry, LinkFacts } from './assertions/facts/linked_meshes.ts';
113
+ import type { ConstraintEntry, ConstraintFacts, ConstraintTimeline as ConstraintTimelineFact } from './assertions/facts/constraints.ts';
114
+ import { switchedOn } from './assertions/constraint_words.ts';
115
+ import { a04MeshTrianglesAndEncoding } from './assertions/bodies/a04.ts';
116
+ import { a20MeshWeightsCoherent } from './assertions/bodies/a20.ts';
117
+ import { a21MeshRimPinned } from './assertions/bodies/a21.ts';
118
+ import { a23PhysicsConstraintEffective } from './assertions/bodies/a23.ts';
119
+ import { a28RibbonRowsShareWeights } from './assertions/bodies/a28.ts';
120
+ import { a33VertexAttachmentGeometry } from './assertions/bodies/a33.ts';
121
+ import { a36PathConstraintEffective } from './assertions/bodies/a36.ts';
122
+ import { a37SliderConstraintEffective } from './assertions/bodies/a37.ts';
123
+ import { a42DrivenConstraintsUpdateAfterTheirDriver } from './assertions/bodies/a42.ts';
124
+ import { a44LinkedMeshStatesNoGeometryOfItsOwn } from './assertions/bodies/a44.ts';
125
+ import { a47IkConstraintNotMutedThroughout } from './assertions/bodies/a47.ts';
126
+ import { a48TransformConstraintNotMutedThroughout } from './assertions/bodies/a48.ts';
127
+ import type { RosterBone, RosterSlot, SkeletonRosterFacts } from './assertions/facts/skeleton_roster.ts';
128
+ import type { BoneTimelineFacts, BoneTimelines } from './assertions/facts/bone_timelines.ts';
129
+ import type { EventKeyEntry, EventKeyFacts } from './assertions/facts/event_keys.ts';
130
+ import { a12NoDarkColor } from './assertions/bodies/a12.ts';
131
+ import { a24AxisSpaceStroke } from './assertions/bodies/a24.ts';
132
+ import { a25DetachedBoneParentage } from './assertions/bodies/a25.ts';
133
+ import { a26SlotDrawOrder } from './assertions/bodies/a26.ts';
134
+ import { a29StrokeWithinContactDepth } from './assertions/bodies/a29.ts';
135
+ import { a30StrokeWithinCapContainment } from './assertions/bodies/a30.ts';
136
+ import { a32EventKeysResolve, eventKeyAt } from './assertions/bodies/a32.ts';
137
+ import {
138
+ SKIP_NO_ANIMATION,
139
+ SKIP_NO_ATLAS,
140
+ SKIP_NO_ATLAS_PAGE,
141
+ SKIP_NO_MESH_ATTACHMENT,
142
+ SKIP_NO_POSE,
143
+ SKIP_NO_SKELETON,
144
+ SKIP_NO_TIMELINE,
145
+ } from './assertions/reasons.ts';
146
+ import type { DeformSurveyFacts } from './assertions/facts/deform_survey.ts';
147
+ import type { AnimationDurationFacts } from './assertions/facts/animation_durations.ts';
148
+ import type { TwoColourFacts } from './assertions/facts/two_colour.ts';
149
+ import { entryAddress, type EntryAddress, type SequenceFacts, type SequenceSkinEntry, type SequenceTimeline } from './assertions/facts/sequences.ts';
150
+ import { a39DeformKeepsTriangleWinding } from './assertions/bodies/a39.ts';
151
+ import { a09AnimationDurationMatchesSpec } from './assertions/bodies/a09.ts';
152
+ import { a43TwoColorTintLoadsAndPosesAsWritten } from './assertions/bodies/a43.ts';
153
+ import { a46SequenceAttachmentsShowTheFrameTheFileStates } from './assertions/bodies/a46.ts';
154
+ import type { SliderCompositionFacts, SliderFact, SliderTimelineFact } from './assertions/facts/slider_composition.ts';
155
+ import type { ConstraintTargetFacts, TargetAnimation, TargetKeyArray } from './assertions/facts/constraint_targets.ts';
156
+ import { a34ConstraintTimelineTargets } from './assertions/bodies/a34.ts';
157
+ import { a40SlidersComposeOnASharedTarget } from './assertions/bodies/a40.ts';
158
+ import { a10NoNanAfterStepping } from './assertions/bodies/a10.ts';
159
+ import type { SteppedFrame, SteppedPoseFacts } from './assertions/facts/stepped_poses.ts';
160
+
161
+ export type { Failure } from './assertions/harness.ts';
162
+
163
+ /**
164
+ * Which body of rules to hold the artifact to.
165
+ *
166
+ * ⭐ The distinction this draws is the difference between "wrong" and "not how we
167
+ * do it here", and conflating the two is how a validator stops being usable on
168
+ * anybody else's data. Fifteen of the 40 assertions are policy — seven for one
169
+ * renderer (`spine-html`) and one project's canvas budget, eight for rigc's own
170
+ * formations — and every one of them fires
171
+ * on real, correct, editor-produced Spine data — the official example projects
172
+ * carry clipping attachments, unweighted meshes, 116-triangle meshes and packed
173
+ * atlases, all of which are perfectly valid and none of which spine-html likes.
174
+ *
175
+ * - `spine` — is this valid Spine 4.3 that any runtime will play correctly?
176
+ * - `spine-html` — the above, plus this project's renderer and archetype policy.
177
+ *
178
+ * ⚠️ `validate()` has NO default profile — `ValidateInput.profile` is required,
179
+ * and that is deliberate. A silent default here can only be wrong in one of two
180
+ * directions: loosen it and a caller who did not ask gets a weaker gate than the
181
+ * one they think they ran; tighten it and foreign data is refused by a policy the
182
+ * caller has no stake in. Issue #221 flipped the CLI to `spine` while several
183
+ * internal callers still wanted `spine-html`, at which point one constant could
184
+ * no longer honestly serve both — so the choice is made at every call site now,
185
+ * by the caller who knows which question they are asking.
186
+ */
187
+ export type ValidateProfile = AssertionProfile;
188
+
189
+ // The profiles, the CLI's default and the report printer — `./assertions/report.ts` since issue #1060, so the
190
+ // entry that links none of spine-core gates and prints with them; every one is re-exported from here.
191
+ export { CLI_DEFAULT_PROFILE, reportLines, VALIDATE_PROFILES } from './assertions/report.ts';
192
+
193
+ // What kind of rule each assertion is — `./assertions/kinds.ts` since issue
194
+ // #1025, where the model side's harness reads the same table.
195
+
196
+ /**
197
+ * Every assertion this validator knows, in registry order.
198
+ *
199
+ * Exported so a control can count them instead of quoting a number that goes
200
+ * stale the next time one is added — two selftest cases used to assert `39` and
201
+ * `14` as literals, which is a guardrail that has to be remembered rather than
202
+ * one that holds.
203
+ */
204
+ export const ASSERTION_NAMES: readonly string[] = Object.keys(ASSERTION_KIND);
205
+
206
+ /** How many assertions this profile applies, by the kinds it carries. */
207
+ export function assertionCountForProfile(profile: ValidateProfile): number {
208
+ if (profile === 'spine-html') return ASSERTION_NAMES.length;
209
+ return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
210
+ }
211
+
212
+ // What a SKIP says, by what the assertion was denied — `./assertions/reasons.ts`
213
+ // since issue #1025, so the assertion bodies that moved out of this file print
214
+ // the same sentences on both sides; every one is re-exported from here.
215
+ export {
216
+ SKIP_NO_ANIMATION,
217
+ SKIP_NO_ATLAS,
218
+ SKIP_NO_ATLAS_PAGE,
219
+ SKIP_NO_ATLAS_REGION,
220
+ SKIP_NO_ATTACHMENT_REGION_JOIN,
221
+ SKIP_NO_DECLARED_DURATION,
222
+ SKIP_NO_LINKED_MESH,
223
+ SKIP_NO_MESH_ATTACHMENT,
224
+ SKIP_NO_PHYSICS_CONSTRAINT,
225
+ SKIP_NO_POSE,
226
+ SKIP_NO_REGION_ATTACHMENT,
227
+ SKIP_NO_SEPARABLE_COLOR,
228
+ SKIP_NO_SEQUENCE,
229
+ SKIP_NO_SKELETON,
230
+ SKIP_NO_TIMELINE,
231
+ SKIP_NO_TWO_COLOR_TINT,
232
+ } from './assertions/reasons.ts';
233
+
234
+ export interface ValidateInput {
235
+ skeletonText: string;
236
+ atlasText: string;
237
+ /** Directory the atlas lives in; page names resolve against it. */
238
+ atlasDir: string;
239
+ /** Declared durations from the motion spec. */
240
+ declaredDurations?: Record<string, number>;
241
+ /**
242
+ * The compiled model's document (`modelDocument`, `rigc-compiled/1`) — the
243
+ * third file `build` writes. Required whenever `reEmit` is given, since `A18`
244
+ * compares it with the second compile's (issue #922).
245
+ */
246
+ modelText?: string;
247
+ /** Re-emitted artifacts, for the determinism check: a second, independent compile's three texts. */
248
+ reEmit?: { skeletonText: string; atlasText: string; modelText: string };
249
+ /**
250
+ * Structural expectations the artifact cannot state about itself: which mesh is
251
+ * a ribbon, which bone carries the axis, which parentage is forbidden, what the
252
+ * canonical draw order is. Absent when `validate <dir>` is pointed at a bare
253
+ * directory, and the assertions that need it then SKIP rather than guess — the
254
+ * stats line says `rig=absent` so a green run cannot be mistaken for a full one.
255
+ */
256
+ rig?: RigInfo;
257
+ /**
258
+ * Which body of rules to apply. Required, and deliberately so — there is no
259
+ * default to fall into. See ValidateProfile.
260
+ */
261
+ profile: ValidateProfile;
262
+ }
263
+
264
+ export interface ValidateReport {
265
+ failures: Failure[];
266
+ /** Assertions that ran and passed, in order. */
267
+ passed: string[];
268
+ /**
269
+ * Assertions that had no data to run against, with the reason.
270
+ *
271
+ * ⚠️ Not cosmetic. An assertion whose subject is a per-cut MEASUREMENT (a
272
+ * contact depth, a containment ceiling) is vacuous on a cut that never measured
273
+ * one, and reporting that as PASS is this project's favourite false green: a
274
+ * gate that says it checked something it never looked at. So the report says
275
+ * SKIP and why, and `passed` does not count it.
276
+ */
277
+ skipped: Array<{ assertion: string; reason: string }>;
278
+ /** Which body of rules ran. */
279
+ profile: ValidateProfile;
280
+ /**
281
+ * Assertions this profile does not apply, with their kind.
282
+ *
283
+ * Kept separate from `skipped` on purpose: a SKIP means "there was nothing to
284
+ * look at", a profile skip means "this rule was deliberately out of scope".
285
+ * Reading a `--profile spine` green as though the renderer policy had passed
286
+ * is exactly the misreading the two lists exist to prevent.
287
+ */
288
+ profileSkipped: Array<{ assertion: string; kind: 'renderer' | 'archetype' }>;
289
+ stats: Record<string, number | string>;
290
+ }
291
+
292
+ // The four sentences A39 prints a frame, a dial's reach, a dispute and a tie
293
+ // with, and A09's one-frame slack, are `./assertions/bodies/a39.ts`'s and
294
+ // `./assertions/bodies/a09.ts`'s since issue #1025 (cut 4c-3).
295
+
296
+ /**
297
+ * The constraint groups that spell `group.<constraint>.<timeline>` — a
298
+ * constraint name, then named timelines under it (A34's second shape).
299
+ *
300
+ * A constant rather than a literal in the loop because the same three names
301
+ * have to pick the timeline vocabulary out of `CHANNELS_BY_KIND` for A34's
302
+ * message, and a group listed in one place and not the other is a group whose
303
+ * constraint nobody resolves. `ik` and `transform` are the other shape — one
304
+ * unnamed timeline per constraint — and are enumerated separately there.
305
+ */
306
+ const NAMED_TIMELINE_GROUPS = ['path', 'physics', 'slider'] as const;
307
+
308
+ // The physics components, the muted-at-rest sentences and `SETUP_POSE_SAYS`
309
+ // that stood here are `./assertions/constraint_words.ts`'s and
310
+ // `./assertions/bodies/a23.ts`'s since issue #1025 (cut 4c-2): every rule
311
+ // that read them moved there.
312
+
313
+ /**
314
+ * Every value one channel of a curve timeline can pose while it plays: each
315
+ * key's own value, and every sample of each Bezier segment the parser built
316
+ * between two keys (issue #752).
317
+ *
318
+ * ⚠️ The samples are the half a reading of the keys alone misses. The runtime
319
+ * interpolates a Bezier segment between the points `setBezier` stored
320
+ * (`Animation.js:257-298`), not between the two keys, so two keys of 0 joined
321
+ * by a curve whose handles lie above 0 pose values above 0 in between.
322
+ * [measured] on generated fixtures, a `mix` pair of 0 → 0 with its handles at
323
+ * 0.8 poses a path constraint's bone up to 32.69 away from the flat pair, a
324
+ * slider's by 0.54 and a physics constraint's by 3.81. Between two stored
325
+ * points the runtime is linear, so nothing it poses lies outside this list's
326
+ * range — which is what makes "is any of these above 0" the whole question.
327
+ *
328
+ * Channel `c` of frame `f` is `frames[f * entries + 1 + c]`. `curves[f]` is 0
329
+ * for linear, 1 for stepped and `2 + i` for a Bezier whose points start at `i`
330
+ * (`Animation.js:259-261`); `readCurve` stores one segment per channel in
331
+ * order, so channel `c`'s points start `2 * BEZIER_POINTS * c` further on, as
332
+ * `(time, value)` pairs. The storage is read through `curveStorage`, the one
333
+ * reach into that protected array, which `deformmeasure.ts` owns.
334
+ */
335
+ function curveChannelValues(timeline: CurveTimeline, channel: number): number[] {
336
+ const entries = timeline.getFrameEntries();
337
+ const curves = curveStorage(timeline);
338
+ const values: number[] = [];
339
+ for (let i = 0, frame = 0; i < timeline.frames.length; i += entries, frame++) {
340
+ values.push(timeline.frames[i + 1 + channel]);
341
+ const code = curves[frame];
342
+ if (!(code >= 2)) continue;
343
+ const start = code - 2 + 2 * BEZIER_POINTS * channel;
344
+ for (let point = 0; point < BEZIER_POINTS; point++) values.push(curves[start + 2 * point + 1]);
345
+ }
346
+ return values;
347
+ }
348
+
349
+ /** `physicsTimelineNames`' table, once it has been built. */
350
+ let physicsTimelineNamesTable: Record<number, string> | null = null;
351
+
352
+ /**
353
+ * The skeleton-JSON name of each physics timeline, keyed by the runtime's own
354
+ * `Property` id.
355
+ *
356
+ * ⚠️ Derived from the enum rather than from `timeline instanceof
357
+ * PhysicsConstraintMassTimeline` and rather than from a list of digits: the ids
358
+ * are what `ConstraintTimeline1` puts in its propertyId (`<Property>|<index>`),
359
+ * and a renumbering of the enum moves both sides of this map at once. The names
360
+ * on the right are the ones `SkeletonJson`'s physics branch reads
361
+ * (`SkeletonJson.js:1063-1094`), which is also what a motion spec's `property`
362
+ * says.
363
+ *
364
+ * ⚠️ Built on first use, not while the module loads (issue #1014): the
365
+ * computed keys read the runtime's `Property` enum, and seven reads of it at
366
+ * load time were the only spine-core access a module made before a command
367
+ * ran — so `--help`, `render` and `check` on a rigc build all touched the
368
+ * runtime for a table only the gate's physics rules consult.
369
+ */
370
+ export function physicsTimelineNames(): Record<number, string> {
371
+ physicsTimelineNamesTable ??= {
372
+ [Property.physicsConstraintInertia]: 'inertia',
373
+ [Property.physicsConstraintStrength]: 'strength',
374
+ [Property.physicsConstraintDamping]: 'damping',
375
+ [Property.physicsConstraintMass]: 'mass',
376
+ [Property.physicsConstraintWind]: 'wind',
377
+ [Property.physicsConstraintGravity]: 'gravity',
378
+ [Property.physicsConstraintMix]: 'mix',
379
+ };
380
+ return physicsTimelineNamesTable;
381
+ }
382
+
383
+ /**
384
+ * Which physics constraints a physics timeline that names NO constraint writes
385
+ * into — the one reading of the unnamed form, for every rule that has to answer
386
+ * it (`A23`, `A34`, `A42`; issue #726).
387
+ *
388
+ * `SkeletonJson` gives a physics group keyed by the empty name `constraintIndex
389
+ * -1` (`SkeletonJson.js:1048-1054`), and the runtime reads that two ways. A
390
+ * value timeline writes every active physics constraint whose own data declares
391
+ * that property global — `PhysicsConstraintTimeline.apply` asks each subclass's
392
+ * `global` (`Animation.js:2067-2075`). `reset` resets every active one and asks
393
+ * no flag at all (`:2234-2238`). Activity is a skin question and is not asked
394
+ * here: a constraint a skin switches on is still one the timeline can reach.
395
+ *
396
+ * 🔑 `constraints` may be the FILE's objects rather than loaded data, and that
397
+ * is what makes this one reading rather than two: `SkeletonJson` copies each
398
+ * `…Global` field onto the data verbatim (`:313-319`), so the runtime's own
399
+ * `global`, asked of the raw object, reads exactly what it would read of the
400
+ * loaded one — a string `"false"` included, which is truthy to both. A rule that
401
+ * spelled `<property>Global` itself would be a second copy of the runtime's
402
+ * table, free to disagree with it.
403
+ */
404
+ export function unnamedPhysicsReach<C extends object>(timeline: Timeline, constraints: readonly C[]): C[] {
405
+ if (timeline instanceof PhysicsConstraintResetTimeline) return [...constraints];
406
+ if (!(timeline instanceof PhysicsConstraintTimeline)) return [];
407
+ return constraints.filter((one) => Boolean(timeline.global(one as unknown as PhysicsConstraintData)));
408
+ }
409
+
410
+ /**
411
+ * The timeline the runtime builds for one physics timeline NAME, read by the
412
+ * runtime's own parser off a skeleton that holds nothing else — or null for a
413
+ * name its physics branch skips (`SkeletonJson.js:1094`).
414
+ *
415
+ * ⚠️ This is how a raw-JSON rule gets the runtime's class for a name without a
416
+ * table of the eight: a hand-kept map from `"strength"` to
417
+ * `PhysicsConstraintStrengthTimeline` is the copy this avoids, and the parser's
418
+ * `switch` is already that map. The probe skeleton declares no constraint, so
419
+ * the only group it can carry is the unnamed one, and one key is all
420
+ * `readTimeline1` needs to build the object.
421
+ */
422
+ export function unnamedPhysicsTimeline(name: string): Timeline | null {
423
+ const probe = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(''))).readSkeletonData({
424
+ skeleton: {},
425
+ animations: { probe: { physics: { '': { [name]: [{}] } } } },
426
+ });
427
+ return probe.animations[0]?.timelines[0] ?? null;
428
+ }
429
+
430
+ /**
431
+ * The generation `A16` demands, which is the whole of what that assertion is
432
+ * about: the MAJOR.MINOR pair.
433
+ *
434
+ * ⭐ **The reading itself moved to [`generation.ts`](generation.ts)** with issue
435
+ * #706 — one reader of `skeleton.spine` for the whole repository, because
436
+ * `ingest` has to ask the same question of a file somebody else wrote and two
437
+ * regexes would answer it two ways. What did NOT move is the accepted set:
438
+ * `4.3`, `4.3.<patch>` and `4.3.<patch>-<suffix>`, the last of which is what the
439
+ * editor writes for a pre-release (`"4.3.75-beta"` in all twelve official
440
+ * example exports, and the string the original `/^4\.3(\.\d+)?$/` rejected —
441
+ * blocker B2). `GN05` holds that set against the old regex, which survives in
442
+ * `selftest.ts` and nowhere else, for exactly that comparison.
443
+ */
444
+ const SPINE_4_3: SpineGeneration = '4.3';
445
+
446
+ // `Json`, `isObj` and `atStoredKey` are `./assertions/values.ts`'s since issue
447
+ // #1025: the bodies that moved out of this file read values the same way.
448
+
449
+ // `attachmentRegionLookups` and `AttachmentRegionJoin` are
450
+ // `./assertions/region_lookups.ts`'s since issue #1025: A08's body runs on both
451
+ // sides, and the model side links nothing from the runtime. Re-exported here.
452
+ export { attachmentRegionLookups, type AttachmentRegionJoin };
453
+
454
+ // `attachmentRegionJoins` is `./region_joins.ts`'s since issue #1052: `explain` reads it, and an entry that links
455
+ // nothing of the runtime has to be able to load what `explain` reads. Re-exported here.
456
+ export { attachmentRegionJoins };
457
+
458
+ /**
459
+ * The mesh keys the `source` branch never reaches — the geometry a linked mesh
460
+ * may state and nothing reads (issue #710).
461
+ *
462
+ * Derived from the branch rather than chosen. `readAttachment` returns at
463
+ * `SkeletonJson.js:586` as soon as `source` is truthy, and everything below that
464
+ * return reads `map.uvs` (twice: as the length handed to `readVertices` and as
465
+ * `regionUVs`), `map.triangles`, `map.edges` and `map.hull`. `vertices` is on the
466
+ * list because `readVertices` reads `map.vertices` and nothing else (`:654`), so
467
+ * it goes unread with the call that would have read it.
468
+ *
469
+ * ⚠️ `width` and `height` are NOT on this list, although `setSourceMesh`
470
+ * overwrites both with the source's (`MeshAttachment.js:102-103`). The branch
471
+ * reads them at `:569-570`, the format carries them on a link and rigc emits
472
+ * them (#691). A key the parser reads is not a key the parser ignores, whatever
473
+ * a later pass does with the value.
474
+ */
475
+ const LINKED_MESH_UNREAD_KEYS = ['uvs', 'triangles', 'vertices', 'hull', 'edges'] as const;
476
+
477
+ /** One linked mesh as the FILE spells it, before the loader has resolved anything. */
478
+ interface RawLinkedMesh {
479
+ /** The placeholder of the mesh whose geometry this attachment draws. */
480
+ source: string;
481
+ /**
482
+ * The `skin` the entry states, or `undefined` for the parser's default — which
483
+ * is the DEFAULT skin and never the skin the link is written in (`:429`).
484
+ */
485
+ skin?: string;
486
+ /** The `slot` the entry states, or `undefined` for the parser's default: this attachment's own slot (`:573-579`). */
487
+ slot?: string;
488
+ /** Which of `LINKED_MESH_UNREAD_KEYS` this entry states, in that order. */
489
+ geometry: string[];
490
+ /**
491
+ * How big a mesh those keys describe — `uvs.length / 2` and
492
+ * `triangles.length / 3` — when the entry states them as arrays.
493
+ *
494
+ * Read so that the failure can put the shape the author wrote beside the shape
495
+ * the runtime draws. `undefined` where the file states the key as something
496
+ * other than an array, which is a file this rule refuses for the key rather
497
+ * than for its length.
498
+ */
499
+ statedVertices?: number;
500
+ statedTriangles?: number;
501
+ }
502
+
503
+ /**
504
+ * `"<skin>\0<slot>\0<placeholder>" -> source` for every linked mesh the raw
505
+ * skeleton declares, with what the file says about each one (issues #691, #710).
506
+ *
507
+ * 🔑 The format decides it, and not by `type`: `type: "mesh"` and
508
+ * `type: "linkedmesh"` share one branch of the reader and a truthy `source` is
509
+ * what decides between them (`SkeletonJson.js:568-569`, `:582`). An empty
510
+ * `source` is falsy there, so it is not a link here either — that map is read
511
+ * as an ordinary mesh, which is exactly what the runtime does with it.
512
+ */
513
+ function rawLinkedMeshes(raw: unknown): Map<string, RawLinkedMesh> {
514
+ const links = new Map<string, RawLinkedMesh>();
515
+ if (!isObj(raw) || !Array.isArray(raw.skins)) return links;
516
+ for (const skin of raw.skins as unknown[]) {
517
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
518
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
519
+ for (const [slot, entries] of Object.entries(skin.attachments)) {
520
+ if (!isObj(entries)) continue;
521
+ for (const [placeholder, entry] of Object.entries(entries)) {
522
+ if (!isObj(entry)) continue;
523
+ const type = entry.type === undefined ? 'region' : entry.type;
524
+ if (type !== 'mesh' && type !== 'linkedmesh') continue;
525
+ const source = entry.source;
526
+ if (typeof source !== 'string' || source.length === 0) continue;
527
+ links.set(`${skinName}\u0000${slot}\u0000${placeholder}`, {
528
+ source,
529
+ skin: typeof entry.skin === 'string' ? entry.skin : undefined,
530
+ slot: typeof entry.slot === 'string' ? entry.slot : undefined,
531
+ geometry: LINKED_MESH_UNREAD_KEYS.filter((key) => entry[key] !== undefined),
532
+ statedVertices: Array.isArray(entry.uvs) ? entry.uvs.length / 2 : undefined,
533
+ statedTriangles: Array.isArray(entry.triangles) ? entry.triangles.length / 3 : undefined,
534
+ });
535
+ }
536
+ }
537
+ }
538
+ return links;
539
+ }
540
+
541
+ /**
542
+ * How long the array a deform key edits is, read off one raw attachment — or
543
+ * `null` when the file does not say.
544
+ *
545
+ * The rule is `readVertices`' own: the attachment's `vertices` is coordinates
546
+ * when its length equals `worldVerticesLength`, and a weight run otherwise. A
547
+ * deform array is therefore one `x, y` pair per **vertex** in the first case and
548
+ * one per **bone influence** (`vertices.length / 3`) in the second — the same
549
+ * count with two different meanings, which is exactly why this measures rather
550
+ * than assumes.
551
+ *
552
+ * `null` for the two shapes that cannot be measured from this object alone: a
553
+ * type with no vertices at all (nothing to deform, and the parser throws on it),
554
+ * and a `linkedmesh`, whose geometry belongs to another attachment.
555
+ */
556
+ function deformArrayLength(att: Json): number | null {
557
+ const type = typeof att.type === 'string' ? att.type : 'region';
558
+ let worldVerticesLength: number;
559
+ if (type === 'mesh') {
560
+ if (!Array.isArray(att.uvs)) return null;
561
+ worldVerticesLength = att.uvs.length;
562
+ } else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
563
+ if (typeof att.vertexCount !== 'number') return null;
564
+ worldVerticesLength = att.vertexCount * 2;
565
+ } else {
566
+ return null;
567
+ }
568
+ if (!Array.isArray(att.vertices)) return null;
569
+ const vertices = att.vertices as unknown[];
570
+ if (vertices.length === worldVerticesLength) return worldVerticesLength;
571
+ // ⚠️ Weighted, and the length is the INFLUENCE COUNT — which is the sum of the
572
+ // per-vertex bone counts, not a division of this array's length. The file
573
+ // holds `boneCount` followed by `boneIndex, x, y, weight` per influence, so a
574
+ // one-bone vertex is FIVE numbers; `readVertices` unpacks that into three
575
+ // numbers per influence, which is where the parser's own `/3*2` comes from and
576
+ // exactly why it cannot be applied to the raw form. Applied here it measured
577
+ // `gallery/flex`'s 77-vertex leaf at 256.667 against its true 154 and put
578
+ // A35's bar two thirds too wide on every weighted mesh — the silence A35
579
+ // exists to break, arriving inside A35.
580
+ //
581
+ // Derived here rather than shared with `src/compile.ts`'s own walk on purpose:
582
+ // the gate re-derives from the emitted file so that it is not checking the
583
+ // compiler's assumptions with the compiler's code. Both had this wrong, which
584
+ // is an argument for a control on each and not for one implementation.
585
+ let influences = 0;
586
+ for (let i = 0; i < vertices.length; ) {
587
+ const n = vertices[i++];
588
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) return null;
589
+ influences += n;
590
+ i += n * 4;
591
+ if (i > vertices.length) return null;
592
+ }
593
+ return influences * 2;
594
+ }
595
+
596
+ /**
597
+ * What one timeline's own `apply` does with the `add` argument.
598
+ *
599
+ * - `accumulates` — a second application adds to the first, so two sliders both
600
+ * `additive` compose on it.
601
+ * - `overwrites` — it writes its value whatever `add` says, so the later slider
602
+ * owns the property and `"additive": true` is not the repair.
603
+ * - `inert` — applied with the arguments a slider passes it changes nothing a
604
+ * pose holds, so there is nothing for a second slider to erase.
605
+ */
606
+ export type TimelineAddBehaviour = 'accumulates' | 'overwrites' | 'inert';
607
+
608
+ /**
609
+ * The displacement the second start state carries, chosen to be exact in binary
610
+ * floating point so the probe adds no rounding of its own.
611
+ */
612
+ const PROBE_DISPLACEMENT = 0.375;
613
+
614
+ /** Every own value of a pose object a timeline could have written, each as its key and its text. */
615
+ function poseParts(pose: object): Array<[string, string]> {
616
+ const parts: Array<[string, string]> = [];
617
+ for (const key of Object.keys(pose).sort()) {
618
+ const value = (pose as Record<string, unknown>)[key];
619
+ if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
620
+ parts.push([key, String(value)]);
621
+ continue;
622
+ }
623
+ if (Array.isArray(value)) {
624
+ const entries: unknown[] = value;
625
+ if (entries.every((one) => typeof one === 'number')) parts.push([key, `[${entries.join(',')}]`]);
626
+ continue;
627
+ }
628
+ if (typeof value !== 'object') continue;
629
+ const record = value as Record<string, unknown>;
630
+ const keys = Object.keys(record).sort();
631
+ // A colour, or anything else built only of numbers. Everything with a
632
+ // structure — a bone back-reference, a slot's data — is either an identity
633
+ // (read by name below) or something no timeline writes.
634
+ if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
635
+ parts.push([key, `{${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`]);
636
+ } else if (typeof record.name === 'string') {
637
+ parts.push([key, `@${record.name}`]);
638
+ }
639
+ }
640
+ return parts;
641
+ }
642
+
643
+ /** Every own value of a pose object a timeline could have written, as text. */
644
+ function poseReading(pose: object): string {
645
+ return poseParts(pose).map(([key, text]) => `${key}=${text}`).join(',');
646
+ }
647
+
648
+ /** The whole of what a timeline could have changed, in one comparable string. */
649
+ function skeletonReading(skeleton: Skeleton): string {
650
+ const parts: string[] = [];
651
+ for (const bone of skeleton.bones) parts.push(poseReading(bone.appliedPose));
652
+ for (const slot of skeleton.slots) parts.push(poseReading(slot.appliedPose));
653
+ for (const constraint of skeleton.constraints) parts.push(poseReading(constraint.appliedPose as object));
654
+ parts.push(skeleton.drawOrder.appliedPose.map((slot) => slot.data.name).join('>'));
655
+ return parts.join('|');
656
+ }
657
+
658
+ /**
659
+ * The two start states a probe is taken from, in order: the setup pose, and the
660
+ * setup pose displaced.
661
+ *
662
+ * ⚠️ Both are needed and neither is redundant. From the **setup** pose a
663
+ * `DeformTimeline` is live, because its `apply` is gated on the slot still
664
+ * holding the attachment the timeline names. From the **displaced** pose a
665
+ * timeline whose every key states its target's own setup value is still seen to
666
+ * write, which from the setup pose alone would read as `inert` — a later slider
667
+ * keying a slot's colour to exactly the colour it already has erases an earlier
668
+ * one just the same.
669
+ */
670
+ function probeStartStates(skeleton: Skeleton): Array<() => void> {
671
+ return [
672
+ () => undefined,
673
+ () => {
674
+ for (const bone of skeleton.bones) displacePose(bone.appliedPose);
675
+ for (const slot of skeleton.slots) {
676
+ displacePose(slot.appliedPose);
677
+ slot.appliedPose.setAttachment(null);
678
+ }
679
+ for (const constraint of skeleton.constraints) displacePose(constraint.appliedPose as object);
680
+ skeleton.drawOrder.appliedPose.push(...skeleton.drawOrder.appliedPose.splice(0, 1));
681
+ },
682
+ ];
683
+ }
684
+
685
+ /** Move every number a pose holds, so "it wrote nothing" cannot mean "it wrote what was there". */
686
+ function displacePose(pose: object): void {
687
+ for (const key of Object.keys(pose)) {
688
+ const value = (pose as Record<string, unknown>)[key];
689
+ if (typeof value === 'number') {
690
+ (pose as Record<string, number>)[key] = value + PROBE_DISPLACEMENT;
691
+ continue;
692
+ }
693
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
694
+ const record = value as Record<string, unknown>;
695
+ const keys = Object.keys(record);
696
+ if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
697
+ for (const one of keys) (record as Record<string, number>)[one] += PROBE_DISPLACEMENT;
698
+ }
699
+ }
700
+ }
701
+
702
+ /** Every time the timeline's own frames name, the midpoints between them, and one past the end. */
703
+ function probeTimes(timeline: Timeline): number[] {
704
+ const stride = timeline.getFrameEntries();
705
+ const frames = timeline.frames;
706
+ const at: number[] = [];
707
+ for (let i = 0; i < frames.length; i += stride) at.push(frames[i]);
708
+ const between: number[] = [];
709
+ for (let i = 1; i < at.length; i++) between.push((at[i - 1] + at[i]) / 2);
710
+ return [...at, ...between, (at.length ? at[at.length - 1] : 0) + 1];
711
+ }
712
+
713
+ /**
714
+ * 🚨 **What `apply` does with `add`, posed rather than read off a flag.**
715
+ *
716
+ * `Timeline.additive` is the runtime's own declaration that a class "supports
717
+ * being applied additively", and for two classes it is not what the class does:
718
+ * `PathConstraintMixTimeline` and `SliderTimeline` declare `false` and pass the
719
+ * `add` argument straight through anyway. A40 read the flag, so it refused two
720
+ * correct rigs with a sentence about the runtime the runtime does not perform
721
+ * (issue #655, measured by `PS143` over all thirty spellings of the motion
722
+ * vocabulary).
723
+ *
724
+ * So the answer is taken from the object instead. The timeline is applied with
725
+ * exactly the arguments `Slider.update` passes — `firedEvents` null, `alpha` 1,
726
+ * `MixFrom.current`, `appliedPose` true — **twice**, at every time its own
727
+ * frames name. A second application that moves the pose again is a class that
728
+ * honours `add`; one that lands on the same value is a class that writes
729
+ * outright; one that never moves the pose at all writes nothing a second slider
730
+ * could erase, which is what an events timeline under a slider *is*.
731
+ *
732
+ * ⛔ Rejected: reading the source of `apply` (not a measurement of anything, and
733
+ * a class whose body changes under a runtime bump would still read the old
734
+ * answer), and a table of the two disagreeing classes written here (the same
735
+ * hand-kept list this repository has a judgment about — it would have been
736
+ * written after the census found two and been wrong at the third).
737
+ *
738
+ * ⚠️ **The skeleton wears every skin in turn, and that is not thoroughness.**
739
+ * `Skeleton.updateCache` leaves a `skinRequired` constraint inactive under the
740
+ * skins that do not list it, and an inactive target makes ANY timeline read
741
+ * `inert` — a fact about that skin, not about the class. Reading one skin would
742
+ * put a whole class in the third state and quietly stop refusing pairs that
743
+ * share it, which is this repository's worst failure shape: a gate that looks
744
+ * kept while checking nothing. The strongest verdict any skin produces wins.
745
+ */
746
+ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeletonData']>, timeline: Timeline): TimelineAddBehaviour {
747
+ let wrote = false;
748
+ for (const skin of [null, ...data.skins]) {
749
+ const skeleton = new Skeleton(data);
750
+ if (skin !== null) skeleton.setSkin(skin);
751
+ for (const displace of probeStartStates(skeleton)) {
752
+ for (const time of probeTimes(timeline)) {
753
+ skeleton.setupPose();
754
+ for (const bone of skeleton.bones) bone.resetConstrained();
755
+ for (const slot of skeleton.slots) slot.resetConstrained();
756
+ for (const constraint of skeleton.constraints) constraint.resetConstrained();
757
+ skeleton.drawOrder.resetConstrained();
758
+ displace();
759
+ const before = skeletonReading(skeleton);
760
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
761
+ const once = skeletonReading(skeleton);
762
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
763
+ if (skeletonReading(skeleton) !== once) return 'accumulates';
764
+ if (once !== before) wrote = true;
765
+ }
766
+ }
767
+ }
768
+ return wrote ? 'overwrites' : 'inert';
769
+ }
770
+
771
+ /** One skin under one start state of `timelineAddBehaviour`'s probe: what the cell reads, and every pose field the applications moved. */
772
+ export interface TimelineAddCell {
773
+ /** The skin worn, `(none)` for no skin set. */
774
+ view: string;
775
+ state: 'setup' | 'displaced';
776
+ /** `A` a second application moved the pose at some time, `W` only the first did, `-` neither ever did. */
777
+ letter: 'A' | 'W' | '-';
778
+ /** 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. */
779
+ changed: Array<{ field: string; t: number; before: string; once: string; twice: string }>;
780
+ }
781
+
782
+ /**
783
+ * `timelineAddBehaviour`'s probe, every cell kept: the same skins, start states,
784
+ * times and arguments, read per field rather than as one string — what the
785
+ * core suite's `CO27` holds the core's additive probe (`src/core/additive.ts`)
786
+ * to, cell by cell and value by value (issue #1025, cut 4c-5). The class the
787
+ * cells read is held to `timelineAddBehaviour`'s own answer on every timeline
788
+ * there, so the two cannot drift.
789
+ */
790
+ export function timelineAddCells(data: ReturnType<SkeletonJson['readSkeletonData']>, timeline: Timeline): TimelineAddCell[] {
791
+ const fields = (skeleton: Skeleton): Map<string, string> => {
792
+ const out = new Map<string, string>();
793
+ const take = (kind: string, name: string, pose: object): void => {
794
+ for (const [key, text] of poseParts(pose)) out.set(`${kind}:${name}.${key}`, text);
795
+ };
796
+ for (const bone of skeleton.bones) take('bone', bone.data.name, bone.appliedPose);
797
+ for (const slot of skeleton.slots) take('slot', slot.data.name, slot.appliedPose);
798
+ for (const constraint of skeleton.constraints) take('constraint', constraint.data.name, constraint.appliedPose as object);
799
+ out.set('drawOrder', skeleton.drawOrder.appliedPose.map((slot) => slot.data.name).join('>'));
800
+ return out;
801
+ };
802
+ const cells: TimelineAddCell[] = [];
803
+ for (const skin of [null, ...data.skins]) {
804
+ const skeleton = new Skeleton(data);
805
+ if (skin !== null) skeleton.setSkin(skin);
806
+ probeStartStates(skeleton).forEach((displace, at) => {
807
+ const cell: TimelineAddCell = { view: skin?.name ?? '(none)', state: at === 0 ? 'setup' : 'displaced', letter: '-', changed: [] };
808
+ for (const time of probeTimes(timeline)) {
809
+ skeleton.setupPose();
810
+ for (const bone of skeleton.bones) bone.resetConstrained();
811
+ for (const slot of skeleton.slots) slot.resetConstrained();
812
+ for (const constraint of skeleton.constraints) constraint.resetConstrained();
813
+ skeleton.drawOrder.resetConstrained();
814
+ displace();
815
+ const before = fields(skeleton);
816
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
817
+ const once = fields(skeleton);
818
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
819
+ const twice = fields(skeleton);
820
+ let wrote = false;
821
+ let again = false;
822
+ for (const [field, b] of before) {
823
+ const o = once.get(field) ?? '';
824
+ const w = twice.get(field) ?? '';
825
+ if (o !== b) wrote = true;
826
+ if (w !== o) again = true;
827
+ if (o !== b || w !== o) cell.changed.push({ field, t: time, before: b, once: o, twice: w });
828
+ }
829
+ if (again) cell.letter = 'A';
830
+ else if (wrote && cell.letter === '-') cell.letter = 'W';
831
+ }
832
+ cells.push(cell);
833
+ });
834
+ }
835
+ return cells;
836
+ }
837
+
838
+ /**
839
+ * The runtime's supply of `SkinEntryFacts` (issue #1025): every skin of the
840
+ * loaded skeleton in the loaded order, and each skin's entries as
841
+ * `getAttachments()` lists them — the walk this file's prelude makes for its
842
+ * own lists, made once more here so the facts the moved bodies read are a
843
+ * value the selftest can compare with the model side's, order included.
844
+ */
845
+ export function spineSkinEntries(data: ReturnType<SkeletonJson['readSkeletonData']>): SkinEntryFacts {
846
+ const regionAttachments: RegionAttachment[] = [];
847
+ let clippingCount = 0;
848
+ for (const skin of data.skins) {
849
+ for (const entry of skin.getAttachments()) {
850
+ if (entry.attachment instanceof RegionAttachment) regionAttachments.push(entry.attachment);
851
+ else if (entry.attachment instanceof ClippingAttachment) clippingCount++;
852
+ }
853
+ }
854
+ return { regionAttachments, clippingCount };
855
+ }
856
+
857
+ /**
858
+ * The facts the moved bodies read, as `validate()` supplies them from a pair
859
+ * spine-core loads — or `null` when the load throws, which is A00's failure
860
+ * and `validate()`'s to name. For the selftest and `tools/verdict_gate.ts`,
861
+ * which compare these with the model side's fact by fact, where a verdict
862
+ * line would hide a difference (an order nothing failed on, a name no line
863
+ * printed); `validate()` itself builds them from its own round trip.
864
+ */
865
+ export function runtimeFacts(skeletonText: string, atlasText: string, modelText?: string): {
866
+ skinEntries: SkinEntryFacts;
867
+ atlasPages: AtlasPageFacts;
868
+ slotColour: SlotColourFacts;
869
+ animatedBones: AnimatedBoneFacts;
870
+ skinMembers: SkinMemberFacts;
871
+ regionJoins: RegionJoinFacts;
872
+ atlasRegions: AtlasRegionFacts;
873
+ stage: StageFacts;
874
+ skeletonRoster: SkeletonRosterFacts;
875
+ boneTimelines: BoneTimelineFacts;
876
+ eventKeys: EventKeyFacts;
877
+ } | null {
878
+ let atlas: TextureAtlas;
879
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
880
+ try {
881
+ atlas = new TextureAtlas(atlasText);
882
+ data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(JSON.parse(skeletonText));
883
+ } catch {
884
+ return null;
885
+ }
886
+ const raw = JSON.parse(skeletonText) as Json;
887
+ return {
888
+ skinEntries: spineSkinEntries(data),
889
+ atlasPages: { atlas },
890
+ slotColour: spineSlotColourFacts(raw, data),
891
+ animatedBones: spineAnimatedBones(raw),
892
+ skinMembers: data,
893
+ regionJoins: spineRegionJoins(atlasText, raw),
894
+ atlasRegions: { atlas },
895
+ stage: spineStage(data, modelText),
896
+ skeletonRoster: rawSkeletonRoster(raw),
897
+ boneTimelines: rawBoneTimelines(raw),
898
+ eventKeys: rawEventKeys(raw),
899
+ };
900
+ }
901
+
902
+ /**
903
+ * The runtime's supply of `SkeletonRosterFacts` (issue #1025, cut 4c-4): the
904
+ * skeleton JSON's `bones` and `slots`, read as A12, A25 and A26 always read
905
+ * them — a slot is any object in the array, named `String(name)`, dark where
906
+ * the key is present; a bone is an object with a string name, its parent the
907
+ * string the file states or `null`. Off the raw JSON because those three run
908
+ * whatever the round trip did. The model side's supply is
909
+ * `./assertions/model/skeleton_roster.ts`.
910
+ */
911
+ export function rawSkeletonRoster(raw: Json | null): SkeletonRosterFacts {
912
+ const bones: RosterBone[] = [];
913
+ for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
914
+ if (isObj(bone) && typeof bone.name === 'string') bones.push({ name: bone.name, parent: typeof bone.parent === 'string' ? bone.parent : null });
915
+ }
916
+ const slots: RosterSlot[] = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []).filter(isObj).map((s) => ({ name: String(s.name), dark: 'dark' in s }));
917
+ return { bones, slots };
918
+ }
919
+
920
+ /**
921
+ * The slot timelines of the skeleton JSON in the file's order — every
922
+ * animation that is an object, every slot entry that is an object — the walk
923
+ * `walkTimelines` made for A12 (issue #1025, cut 4c-4), and the same walk
924
+ * A45's supply makes (`spineSlotColourFacts`), here without the loaded
925
+ * skeleton because A12 runs whatever the round trip did. The model side
926
+ * supplies the same list (`fileSlotTimelines`).
927
+ */
928
+ export function rawSlotTimelines(raw: Json | null): SlotTimelines[] {
929
+ const out: SlotTimelines[] = [];
930
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
931
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
932
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
933
+ for (const [slot, timelines] of Object.entries(anim.slots)) {
934
+ if (!isObj(timelines)) continue;
935
+ out.push({ animation, slot, timelines });
936
+ }
937
+ }
938
+ return out;
939
+ }
940
+
941
+ /**
942
+ * The runtime's supply of `BoneTimelineFacts` (issue #1025, cut 4c-4): every
943
+ * (animation, bone) pair of the skeleton JSON, in the file's order — every
944
+ * animation that is an object with a `bones` object, every bone entry,
945
+ * whatever it holds — the walk A24, A29 and A30 made over the raw JSON. The
946
+ * model side's supply is `./assertions/model/bone_timelines.ts`.
947
+ */
948
+ export function rawBoneTimelines(raw: Json | null): BoneTimelineFacts {
949
+ const boneTimelines: BoneTimelines[] = [];
950
+ const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
951
+ for (const [animation, anim] of Object.entries(anims)) {
952
+ if (!isObj(anim) || !isObj(anim.bones)) continue;
953
+ for (const [bone, timelines] of Object.entries(anim.bones as Json)) boneTimelines.push({ animation, bone, timelines });
954
+ }
955
+ return { boneTimelines };
956
+ }
957
+
958
+ /**
959
+ * The runtime's supply of `EventKeyFacts` (issue #1025, cut 4c-4) — and the
960
+ * four clauses of A32 that stay with the round trip.
961
+ *
962
+ * Every event key of the skeleton JSON, in the file's order, with what the
963
+ * body's one moved clause reads (the fields the key states, whether its event
964
+ * declares an audio path). The four clauses below run here, over the raw JSON,
965
+ * exactly as they ran in A32's body, and hand their findings in as `kept`, in
966
+ * the order they printed, with `stopped` where the clause ended the key's
967
+ * reading (the body prints them at the same place). They stay because each is
968
+ * a state the model document's reader refuses by name (`readEventKeys` in
969
+ * `src/core/events.ts`): a key with no string name, an event the skeleton
970
+ * does not declare, a time that is not a finite number, a time before the key
971
+ * before it — measured on forged documents, each refused at the key's
972
+ * address, so no readable document reaches them and the model side has no
973
+ * input to print them from.
974
+ */
975
+ export function rawEventKeys(raw: Json | null): EventKeyFacts {
976
+ if (!raw) return { parsed: false, animations: false, timelines: 0, keys: [] };
977
+ if (!isObj(raw.animations)) return { parsed: true, animations: false, timelines: 0, keys: [] };
978
+ const declared = isObj(raw.events) ? (raw.events as Json) : {};
979
+ const known = Object.keys(declared);
980
+ const keys: EventKeyEntry[] = [];
981
+ let timelines = 0;
982
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
983
+ if (!isObj(anim) || !Array.isArray(anim.events)) continue;
984
+ timelines++;
985
+ let previous = -Infinity;
986
+ (anim.events as unknown[]).forEach((key, k) => {
987
+ const at = eventKeyAt(animName, k);
988
+ const kept: string[] = [];
989
+ const stop = (name: string): void => void keys.push({ animation: animName, index: k, kept, stopped: true, name, sets: { volume: false, balance: false }, audio: false });
990
+ if (!isObj(key) || typeof key.name !== 'string') {
991
+ kept.push(`${at}: an event key needs a string "name"`);
992
+ return stop('');
993
+ }
994
+ const definition = declared[key.name];
995
+ if (definition === undefined) {
996
+ kept.push(
997
+ `${at}: fires "${key.name}", which the skeleton's events block does not declare` +
998
+ (known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
999
+ );
1000
+ return stop(key.name);
1001
+ }
1002
+ // `time` defaults to 0 when absent (`:1247`), which is what the editor
1003
+ // writes for a firing on frame 0.
1004
+ const time = key.time === undefined ? 0 : key.time;
1005
+ if (typeof time !== 'number' || !Number.isFinite(time)) {
1006
+ kept.push(`${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
1007
+ return stop(key.name);
1008
+ }
1009
+ if (time < previous) {
1010
+ kept.push(
1011
+ `${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
1012
+ 'array order and never sorts them, so the earlier firing is unreachable',
1013
+ );
1014
+ }
1015
+ previous = Math.max(previous, time);
1016
+ keys.push({
1017
+ animation: animName,
1018
+ index: k,
1019
+ kept,
1020
+ stopped: false,
1021
+ name: key.name,
1022
+ sets: { volume: key.volume !== undefined, balance: key.balance !== undefined },
1023
+ audio: isObj(definition) && typeof definition.audio === 'string',
1024
+ });
1025
+ });
1026
+ }
1027
+ return { parsed: true, animations: true, timelines, keys };
1028
+ }
1029
+
1030
+ /**
1031
+ * The runtime's supply of A45's facts (issue #1025): the slot timelines read
1032
+ * off the skeleton JSON in the file's order — every animation the file keys,
1033
+ * every slot an animation keys, skipping what is not an object, as A45's walk
1034
+ * always read them — whether the loaded skeleton holds an animation, and a
1035
+ * slot's colour posed on a fresh, non-looping track by spine-core: setup pose,
1036
+ * `update(0)`, `updateWorldTransform(Physics.reset)`, then the track stepped to
1037
+ * the time and applied. The model side's supply is
1038
+ * `./assertions/model/slot_colour.ts`; the selftest holds the two to the same
1039
+ * lines.
1040
+ */
1041
+ function spineSlotColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SlotColourFacts {
1042
+ const slotTimelines: SlotTimelines[] = [];
1043
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
1044
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
1045
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
1046
+ for (const [slot, timelines] of Object.entries(anim.slots)) {
1047
+ if (!isObj(timelines)) continue;
1048
+ slotTimelines.push({ animation, slot, timelines });
1049
+ }
1050
+ }
1051
+ return {
1052
+ slotTimelines,
1053
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1054
+ posedSlot: (animName, slotName, time) => {
1055
+ const skeleton = new Skeleton(data);
1056
+ const state = new AnimationState(new AnimationStateData(data));
1057
+ state.setAnimation(0, animName, false);
1058
+ skeleton.setupPose();
1059
+ skeleton.update(0);
1060
+ skeleton.updateWorldTransform(Physics.reset);
1061
+ state.update(time);
1062
+ state.apply(skeleton);
1063
+ return skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
1064
+ },
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * The runtime's supply of A39's fact (issue #1025, cut 4c-3): the survey of
1070
+ * the loaded skeleton, read and posed by spine-core (`surveyDeformKeys`) — the
1071
+ * call A39 always made. The model side's supply is
1072
+ * `./assertions/model/deform_survey.ts`.
1073
+ */
1074
+ export function spineDeformSurvey(data: ReturnType<SkeletonJson['readSkeletonData']>): DeformSurveyFacts {
1075
+ return { survey: (exempt) => surveyDeformKeys(data, exempt) };
1076
+ }
1077
+
1078
+ /**
1079
+ * The runtime's supply of A09's facts (issue #1025, cut 4c-3): the loaded
1080
+ * animations in the loaded order — the file's — each with its duration and
1081
+ * every loaded timeline's `getDuration()`, as A09 always read them. The model
1082
+ * side's supply is `./assertions/model/animation_durations.ts`.
1083
+ */
1084
+ export function spineAnimationDurations(data: ReturnType<SkeletonJson['readSkeletonData']>): AnimationDurationFacts {
1085
+ return { animations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration, timelineDurations: anim.timelines.map((t) => t.getDuration()) })) };
1086
+ }
1087
+
1088
+ /**
1089
+ * The runtime's supply of A43's facts (issue #1025, cut 4c-3): the stated dark
1090
+ * colours read off the skeleton JSON in the file's slot order, each slot named
1091
+ * as A43's walk always named it; the setup dark colour the loaded slot holds;
1092
+ * A45's walk of the file's slot timelines; whether an animation loaded; and a
1093
+ * slot's two colours posed on a fresh, non-looping track by spine-core — setup
1094
+ * pose, `update(0)`, `updateWorldTransform(Physics.reset)`, the track stepped
1095
+ * to the time and applied, then `update` and `updateWorldTransform
1096
+ * (Physics.update)` by the same time, A43's recipe unchanged. The model side's
1097
+ * supply is `./assertions/model/two_colour.ts`.
1098
+ */
1099
+ export function spineTwoColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): TwoColourFacts {
1100
+ const slotDarks: Array<{ slot: string; dark: string }> = [];
1101
+ for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
1102
+ if (isObj(slot) && typeof slot.dark === 'string') slotDarks.push({ slot: String(slot.name), dark: slot.dark });
1103
+ }
1104
+ return {
1105
+ slotDarks,
1106
+ loadedDark: (name) => data.findSlot(name)?.setupPose.darkColor ?? null,
1107
+ slotTimelines: spineSlotColourFacts(raw, data).slotTimelines,
1108
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1109
+ posedTint: (animName, slotName, time) => {
1110
+ const skeleton = new Skeleton(data);
1111
+ const state = new AnimationState(new AnimationStateData(data));
1112
+ state.setAnimation(0, animName, false);
1113
+ skeleton.setupPose();
1114
+ skeleton.update(0);
1115
+ skeleton.updateWorldTransform(Physics.reset);
1116
+ state.update(time);
1117
+ state.apply(skeleton);
1118
+ skeleton.update(time);
1119
+ skeleton.updateWorldTransform(Physics.update);
1120
+ const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
1121
+ if (posed === undefined) return undefined;
1122
+ return { light: posed.color, dark: posed.darkColor ?? null };
1123
+ },
1124
+ };
1125
+ }
1126
+
1127
+ /**
1128
+ * The runtime's supply of A46's facts (issue #1025, cut 4c-3): the skin
1129
+ * entries and sequence timelines read off the skeleton JSON as A46's walks
1130
+ * always read them — the skins, a skin's slot keys and a slot's placeholders in
1131
+ * the file's order, an entry with no `type` a region, an entry of another kind
1132
+ * left out, a timeline whose `sequence` is not a list left out — each entry
1133
+ * marked loaded where the slot exists and the loaded skin of that name holds an
1134
+ * attachment at it; and what a slot shows on a fresh, non-looping track posed
1135
+ * by spine-core — setup pose, `update(0)`, `updateWorldTransform
1136
+ * (Physics.reset)`, the track stepped to the time and applied, A46's recipe
1137
+ * unchanged. The loaded attachment is answered by its address: every loaded
1138
+ * skin's `getAttachments()` gives each attachment object the skin, slot and
1139
+ * placeholder it is filed under, and the object a linked mesh plays its
1140
+ * timelines as (`timelineAttachment`) is answered the same way. The model
1141
+ * side's supply is `./assertions/model/sequences.ts`.
1142
+ */
1143
+ export function spineSequenceFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SequenceFacts {
1144
+ const entries: SequenceSkinEntry[] = [];
1145
+ for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
1146
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
1147
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
1148
+ const loadedSkin = data.findSkin(skinName);
1149
+ for (const [slotName, perSlot] of Object.entries(skin.attachments)) {
1150
+ if (!isObj(perSlot)) continue;
1151
+ const slotIndex = data.findSlot(slotName)?.index;
1152
+ for (const [placeholder, entry] of Object.entries(perSlot)) {
1153
+ if (!isObj(entry)) continue;
1154
+ const type = entry.type === undefined ? 'region' : entry.type;
1155
+ if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
1156
+ const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
1157
+ entries.push({ skin: skinName, slot: slotName, placeholder, name: entry.name, path: entry.path, sequence: entry.sequence, loaded: loaded !== null });
1158
+ }
1159
+ }
1160
+ }
1161
+ const timelines: SequenceTimeline[] = [];
1162
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
1163
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
1164
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
1165
+ for (const [skin, perSkin] of Object.entries(anim.attachments)) {
1166
+ if (!isObj(perSkin)) continue;
1167
+ for (const [slot, perSlot] of Object.entries(perSkin)) {
1168
+ if (!isObj(perSlot)) continue;
1169
+ for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
1170
+ if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
1171
+ timelines.push({ animation, skin, slot, placeholder, keys: perAttachment.sequence as unknown[] });
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+ /** Every loaded attachment object -> the address it is filed under, first filing kept. */
1177
+ let addressOf: Map<object, EntryAddress> | null = null;
1178
+ const address = (attachment: object): EntryAddress => {
1179
+ if (addressOf === null) {
1180
+ addressOf = new Map();
1181
+ for (const skin of data.skins) {
1182
+ for (const entry of skin.getAttachments()) {
1183
+ if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
1184
+ }
1185
+ }
1186
+ }
1187
+ // An attachment no skin files has no address: it can match no timeline's.
1188
+ return addressOf.get(attachment) ?? '';
1189
+ };
1190
+ return {
1191
+ entries,
1192
+ timelines,
1193
+ hasSlot: (slot) => data.findSlot(slot) !== null,
1194
+ duration: (animation) => data.findAnimation(animation)?.duration ?? null,
1195
+ posedFrame: (animName, slotName, time) => {
1196
+ const slotIndex = data.findSlot(slotName)?.index;
1197
+ if (slotIndex === undefined) return null;
1198
+ const skeleton = new Skeleton(data);
1199
+ const state = new AnimationState(new AnimationStateData(data));
1200
+ state.setAnimation(0, animName, false);
1201
+ skeleton.setupPose();
1202
+ skeleton.update(0);
1203
+ skeleton.updateWorldTransform(Physics.reset);
1204
+ state.update(time);
1205
+ state.apply(skeleton);
1206
+ const pose = skeleton.slots[slotIndex].appliedPose;
1207
+ const shown = pose.attachment;
1208
+ if (shown === null) return null;
1209
+ // The object it plays timelines as — `applyToSlot`'s second test — where the attachment carries one.
1210
+ const playsAs = shown.timelineAttachment ?? shown;
1211
+ const sequence = shown instanceof RegionAttachment || shown instanceof MeshAttachment ? shown.sequence : null;
1212
+ const region = sequence === null ? null : ((sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null);
1213
+ return { shown: address(shown), playsAs: address(playsAs), region };
1214
+ },
1215
+ };
1216
+ }
1217
+
1218
+ /**
1219
+ * The runtime's supply of `AnimatedBoneFacts` (issue #1025, cut 4c-1): read
1220
+ * off the skeleton JSON, as A15 always read the bones `idle` keys — an
1221
+ * animation that is not an object is no animation, a `bones` group that is not
1222
+ * an object is no bone timeline. The model side's supply is
1223
+ * `./assertions/model/animated_bones.ts`.
1224
+ */
1225
+ export function spineAnimatedBones(raw: Json | null): AnimatedBoneFacts {
1226
+ return {
1227
+ bonesKeyedBy: (name) => {
1228
+ const animation = isObj(raw?.animations) ? (raw.animations as Json)[name] : undefined;
1229
+ if (!isObj(animation)) return undefined;
1230
+ if (!isObj(animation.bones)) return null;
1231
+ return Object.keys(animation.bones as Json);
1232
+ },
1233
+ };
1234
+ }
1235
+
1236
+ /**
1237
+ * The runtime's supply of `StageFacts` (issue #1025, cut 4c-1): the stage A14
1238
+ * and A19 measure against.
1239
+ *
1240
+ * 🔸 **A rigc build's stage is its model document's, not its header's**
1241
+ * (issue #907). Since then the header of a rigc build carries the setup-pose
1242
+ * bounding box — what the format says it is — and the stage, the working
1243
+ * area the art was painted in, is stated by the `rigc-compiled/3` document
1244
+ * written beside the pair (`stage`, issue #1026). So where the caller hands a
1245
+ * `/3` document (`modelText`), its `stage` is read: the two extents, or both
1246
+ * `undefined` where it states `null`. Everywhere else — an editor export, a
1247
+ * bare directory with no document, a `/2` or `/1` document, written when the
1248
+ * header still was the stage — it is the loaded skeleton's `width` and
1249
+ * `height`, as spine-core holds them (`undefined` where the header states
1250
+ * none), or both absent with no skeleton. An export carries no other box, so
1251
+ * on an export the rules measure against its bounding box, as they always
1252
+ * did.
1253
+ */
1254
+ export function spineStage(data: ReturnType<SkeletonJson['readSkeletonData']> | null, modelText?: string): StageFacts {
1255
+ return documentStage(modelText) ?? { width: data?.width, height: data?.height };
1256
+ }
1257
+
1258
+ /** The stage a `rigc-compiled/3` document states, or `undefined` where there is no such document (`spineStage`). */
1259
+ function documentStage(modelText: string | undefined): StageFacts | undefined {
1260
+ if (modelText === undefined) return undefined;
1261
+ let doc: unknown;
1262
+ try {
1263
+ doc = JSON.parse(modelText);
1264
+ } catch {
1265
+ return undefined;
1266
+ }
1267
+ if (!isObj(doc) || doc.spec !== MODEL_DOCUMENT_SPEC || !('stage' in doc)) return undefined;
1268
+ const stage = doc.stage;
1269
+ if (stage === null) return { width: undefined, height: undefined };
1270
+ if (isObj(stage) && typeof stage.width === 'number' && typeof stage.height === 'number') return { width: stage.width, height: stage.height };
1271
+ return undefined;
1272
+ }
1273
+
1274
+ /**
1275
+ * The runtime's supply of `StageBoxFacts` (issue #1168): the box a
1276
+ * `rigc-compiled/3` document's `stage.box` asks for — nothing asks without
1277
+ * one, and an export has none — and what the loaded skeleton holds there: the
1278
+ * `default` skin's attachment of that name on that slot, its vertices as
1279
+ * loaded, and its world vertices on a fresh skeleton at the setup pose —
1280
+ * `setupPose()`, `updateWorldTransform(Physics.none)`, no skin set — which
1281
+ * is the pose `getBounds` reads for the header and the one a consumer that
1282
+ * loads the files and asks for the box sees.
1283
+ */
1284
+ export function spineStageBox(data: ReturnType<SkeletonJson['readSkeletonData']>, modelText?: string): StageBoxFacts {
1285
+ const asked = documentStageBox(modelText);
1286
+ if (typeof asked === 'string') return { asked: null, why: asked, slot: false, held: null };
1287
+ const slotData = data.findSlot(asked.slot);
1288
+ if (slotData === null) return { asked, why: '', slot: false, held: null };
1289
+ const attachment = data.defaultSkin?.getAttachment(slotData.index, asked.attachment) ?? null;
1290
+ if (attachment === null) return { asked, why: '', slot: true, held: null };
1291
+ if (!(attachment instanceof BoundingBoxAttachment)) {
1292
+ const type =
1293
+ attachment instanceof RegionAttachment ? 'region' : attachment instanceof MeshAttachment ? 'mesh' : attachment instanceof ClippingAttachment ? 'clipping' : attachment instanceof PathAttachment ? 'path' : 'point';
1294
+ return { asked, why: '', slot: true, held: { type, weighted: false, stored: [], world: '' } };
1295
+ }
1296
+ const weighted = attachment.bones !== null && attachment.bones !== undefined;
1297
+ const stored = weighted ? [] : [...attachment.vertices];
1298
+ const skeleton = new Skeleton(data);
1299
+ skeleton.setupPose();
1300
+ skeleton.updateWorldTransform(Physics.none);
1301
+ const slot = skeleton.slots[slotData.index];
1302
+ let world: number[] | string;
1303
+ if (!slot.bone.active) world = `its bone "${slot.bone.data.name}" is inactive with no skin set, so nothing poses it`;
1304
+ else {
1305
+ const out = new Array<number>(attachment.worldVerticesLength).fill(0);
1306
+ attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, out, 0, 2);
1307
+ world = out;
1308
+ }
1309
+ return { asked, why: '', slot: true, held: { type: 'boundingbox', weighted, stored, world } };
1310
+ }
1311
+
1312
+ /** The box a `rigc-compiled/3` document's stage asks for, with that stage — or the SKIP's reason where nothing asks (`spineStageBox`). */
1313
+ function documentStageBox(modelText: string | undefined): StageBoxFacts['asked'] & object | string {
1314
+ if (modelText === undefined) return SKIP_NO_STAGE_BOX;
1315
+ let doc: unknown;
1316
+ try {
1317
+ doc = JSON.parse(modelText);
1318
+ } catch {
1319
+ return SKIP_NO_STAGE_BOX;
1320
+ }
1321
+ if (!isObj(doc) || doc.spec !== MODEL_DOCUMENT_SPEC || !isObj(doc.stage)) return SKIP_NO_STAGE_BOX;
1322
+ const { x, y, width, height, box } = doc.stage;
1323
+ if (typeof x !== 'number' || typeof y !== 'number' || typeof width !== 'number' || typeof height !== 'number') return SKIP_NO_STAGE_BOX;
1324
+ if (!isObj(box)) return SKIP_NO_STAGE_BOX;
1325
+ if (typeof box.slot !== 'string' || typeof box.attachment !== 'string') return SKIP_NO_STAGE_BOX;
1326
+ return { slot: box.slot, attachment: box.attachment, stage: { x, y, width, height } };
1327
+ }
1328
+
1329
+ /**
1330
+ * The runtime's supply of `RegionJoinFacts` (issue #1025, cut 4c-1), read
1331
+ * before the round trip as A08 always read it: the region names of a
1332
+ * `TextureAtlas` built from the atlas text for this alone (`null` when that
1333
+ * throws), and `attachmentRegionJoins` over the raw JSON (`null` when it did
1334
+ * not parse). The model side's supply is `./assertions/model/region_joins.ts`.
1335
+ */
1336
+ export function spineRegionJoins(atlasText: string, raw: Json | null): RegionJoinFacts {
1337
+ let regionNames: string[] | null;
1338
+ try {
1339
+ regionNames = new TextureAtlas(atlasText).regions.map((r) => r.name);
1340
+ } catch {
1341
+ regionNames = null;
1342
+ }
1343
+ return { regionNames, joins: raw ? attachmentRegionJoins(raw) : null };
1344
+ }
1345
+
1346
+ /**
1347
+ * The runtime's supply of `MeshFacts` (issue #1025, cut 4c-2): every mesh
1348
+ * attachment of every loaded skin, in `getAttachments()`'s walk — the walk
1349
+ * the prelude makes for its own lists — with the geometry and the weights
1350
+ * spine-core holds (`meshWeightsOf` decodes the run), each mesh's link as the
1351
+ * FILE spells it (`rawLinkedMeshes`, joined by skin, slot and placeholder, the
1352
+ * parser's own key), and the findings of the clauses that stayed here
1353
+ * because their subject is the encoding:
1354
+ *
1355
+ * - `A04`: a weighted run whose length is not a multiple of three, and an
1356
+ * unweighted array of another length than the `uvs` — the flat run's own
1357
+ * coherence, which a document stating the weighted form outright cannot
1358
+ * spell. [measured] neither is reachable through spine-core's parser (it
1359
+ * reads three numbers per binding, and decides weighted by that very length
1360
+ * comparison), so neither has a mutant; they are kept as the round trip's
1361
+ * statement of what it read, not moved to a side with no run to read.
1362
+ * - `A20`: a binding's index past the bone array. The document names a
1363
+ * binding's bone; the index is the emitter's (`emitVertices`).
1364
+ */
1365
+ export function spineMeshFacts(data: ReturnType<SkeletonJson['readSkeletonData']>, raw: Json | null): MeshFacts {
1366
+ const rawLinks = rawLinkedMeshes(raw);
1367
+ const meshes: MeshAttachmentEntry[] = [];
1368
+ for (const skin of data.skins) {
1369
+ for (const entry of skin.getAttachments()) {
1370
+ const att = entry.attachment;
1371
+ if (!(att instanceof MeshAttachment)) continue;
1372
+ const slot = data.slots[entry.slotIndex];
1373
+ const link = rawLinks.get(`${skin.name}\u0000${slot.name}\u0000${entry.placeholder}`);
1374
+ const weights = att.bones
1375
+ ? meshWeightsOf(att).map((vertex, i) =>
1376
+ vertex.map(({ bone, weight }) => ({
1377
+ bone,
1378
+ weight,
1379
+ ...(bone >= 0 && bone < data.bones.length ? {} : { encoding: `mesh "${att.name}" vertex ${i} references bone index ${bone}` }),
1380
+ })),
1381
+ )
1382
+ : null;
1383
+ const encoding: string[] = [];
1384
+ // Weighted vs unweighted is decided by a length comparison alone — a
1385
+ // coincidental match reads weight data as coordinates.
1386
+ const weighted = !!att.bones;
1387
+ if (weighted && att.vertices.length % 3 !== 0) encoding.push(`mesh "${att.name}" weighted vertex run is not a multiple of 3`);
1388
+ if (!weighted && att.vertices.length !== att.worldVerticesLength) encoding.push(`mesh "${att.name}" unweighted vertices disagree with uvs`);
1389
+ meshes.push({
1390
+ name: att.name,
1391
+ skin: skin.name,
1392
+ slot: slot.name,
1393
+ slotBone: slot.boneData.name,
1394
+ placeholder: entry.placeholder,
1395
+ link: link === undefined ? null : { source: link.source },
1396
+ triangles: att.triangles ?? [],
1397
+ worldVerticesLength: att.worldVerticesLength,
1398
+ regionUVs: Array.from(att.regionUVs ?? []),
1399
+ hullLength: att.hullLength,
1400
+ width: att.width,
1401
+ height: att.height,
1402
+ weights,
1403
+ encoding,
1404
+ });
1405
+ }
1406
+ }
1407
+ return { bones: data.bones.map((b) => b.name), meshes };
1408
+ }
1409
+
1410
+ /**
1411
+ * The runtime's supply of `PolygonFacts` (issue #1025, cut 4c-2): every
1412
+ * bounding box, clipping attachment and path of every loaded skin, as A33
1413
+ * walked them, and every clipping attachment the FILE gives an `end`, read off
1414
+ * the skeleton JSON as A33 always read it — a `null` end slot and an `end`
1415
+ * never written are the same loaded object. Each polygon carries the findings
1416
+ * of the clauses that stayed here because their subject is the encoding: a
1417
+ * weighted run's decode (a vertex claiming no bone, an index past the bone
1418
+ * array, a run decoding to another vertex count, a weight array of the wrong
1419
+ * length) and an unweighted array of the wrong length. A document states the
1420
+ * weighted form outright and names bones, so none of those has a subject
1421
+ * there.
1422
+ */
1423
+ export function spinePolygonFacts(data: ReturnType<SkeletonJson['readSkeletonData']>, raw: Json | null): PolygonFacts {
1424
+ const polygons: PolygonEntry[] = [];
1425
+ for (const skin of data.skins) {
1426
+ for (const entry of skin.getAttachments()) {
1427
+ const att = entry.attachment;
1428
+ let what: string;
1429
+ if (att instanceof BoundingBoxAttachment) what = `bounding box "${att.name}"`;
1430
+ else if (att instanceof ClippingAttachment) what = `clipping attachment "${att.name}"`;
1431
+ // A path is the same shape with one more rule on top: its vertices
1432
+ // are knots AND handles, walked in groups of three.
1433
+ else if (att instanceof PathAttachment) what = `path "${att.name}"`;
1434
+ else continue;
1435
+ polygons.push({
1436
+ what,
1437
+ worldVerticesLength: att.worldVerticesLength,
1438
+ path: att instanceof PathAttachment ? { closed: att.closed, lengths: Array.from(att.lengths) } : null,
1439
+ encoding: polygonRunFindings(what, att, data.bones.length),
1440
+ });
1441
+ }
1442
+ }
1443
+ const clipEnds: ClipEnd[] = [];
1444
+ if (raw && Array.isArray(raw.skins)) {
1445
+ for (const skin of raw.skins as unknown[]) {
1446
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
1447
+ for (const [slotName, perSlot] of Object.entries(skin.attachments as Json)) {
1448
+ if (!isObj(perSlot)) continue;
1449
+ for (const [placeholder, att] of Object.entries(perSlot)) {
1450
+ if (!isObj(att) || att.type !== 'clipping' || att.end === undefined) continue;
1451
+ clipEnds.push({ placeholder, slot: slotName, end: att.end });
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+ return { polygons, clipEnds, slots: data.slots.map((s) => s.name) };
1457
+ }
1458
+
1459
+ /** A33's kept clauses over one polygon's vertex run, in the order it printed them (`spinePolygonFacts`). */
1460
+ function polygonRunFindings(what: string, att: BoundingBoxAttachment | ClippingAttachment | PathAttachment, bones: number): string[] {
1461
+ const out: string[] = [];
1462
+ const length = att.worldVerticesLength;
1463
+ const vertexCount = length / 2;
1464
+ if (!att.bones) {
1465
+ if (att.vertices.length !== length) {
1466
+ out.push(
1467
+ `${what} declares ${vertexCount} vertices but holds ${att.vertices.length} unweighted numbers ` +
1468
+ `(expected ${length}); the parser reads that mismatch as a weighted run`,
1469
+ );
1470
+ }
1471
+ return out;
1472
+ }
1473
+ // Weighted: `bones` is boneCount, (index × boneCount), repeated, and
1474
+ // `vertices` holds x, y, weight per binding.
1475
+ let decoded = 0;
1476
+ let bindings = 0;
1477
+ let ok = true;
1478
+ for (let i = 0; i < att.bones.length; decoded++) {
1479
+ const count = att.bones[i++];
1480
+ if (!Number.isInteger(count) || count < 1 || i + count > att.bones.length) {
1481
+ out.push(`${what} vertex ${decoded} claims ${count} bone(s); the run is malformed`);
1482
+ ok = false;
1483
+ break;
1484
+ }
1485
+ for (let k = 0; k < count; k++, i++) {
1486
+ const index = att.bones[i];
1487
+ if (index < 0 || index >= bones) {
1488
+ out.push(`${what} vertex ${decoded} references bone index ${index}`);
1489
+ ok = false;
1490
+ }
1491
+ }
1492
+ bindings += count;
1493
+ }
1494
+ if (!ok) return out;
1495
+ if (decoded !== vertexCount) out.push(`${what} declares ${vertexCount} vertices and its weighted run decodes to ${decoded}`);
1496
+ if (att.vertices.length !== bindings * 3) out.push(`${what} has ${bindings} binding(s) and ${att.vertices.length} weight numbers (expected ${bindings * 3})`);
1497
+ return out;
1498
+ }
1499
+
1500
+ /**
1501
+ * The runtime's supply of `LinkFacts` (issue #1025, cut 4c-2): every link the
1502
+ * FILE declares (`rawLinkedMeshes`), and — the clause that stayed here,
1503
+ * because a link record holds no geometry field and so the subject exists
1504
+ * only in the Spine text — the finding about each link that states geometry
1505
+ * of its own, with what the runtime made of it (the loaded attachment joined
1506
+ * by skin, slot and placeholder, the parser's own key).
1507
+ */
1508
+ export function spineLinkFacts(data: ReturnType<SkeletonJson['readSkeletonData']>, raw: Json | null): LinkFacts {
1509
+ const rawLinks = rawLinkedMeshes(raw);
1510
+ /**
1511
+ * The pairing the other way round — join key -> the attachment the loader
1512
+ * produced for it. A link whose region is missing loads as `null` and is in
1513
+ * no skin at all (`A08` names that), so its join key is absent here while
1514
+ * the file still declares it.
1515
+ */
1516
+ const loadedLinks = new Map<string, MeshAttachment>();
1517
+ for (const skin of data.skins) {
1518
+ for (const entry of skin.getAttachments()) {
1519
+ if (!(entry.attachment instanceof MeshAttachment)) continue;
1520
+ const join = `${skin.name}\u0000${data.slots[entry.slotIndex].name}\u0000${entry.placeholder}`;
1521
+ if (rawLinks.has(join)) loadedLinks.set(join, entry.attachment);
1522
+ }
1523
+ }
1524
+ const links: LinkEntry[] = [];
1525
+ for (const [join, link] of rawLinks) {
1526
+ const [skinName, slotName, placeholder] = join.split('\u0000');
1527
+ links.push({ skin: skinName, slot: slotName, placeholder, source: link.source, encoding: link.geometry.length === 0 ? [] : [linkGeometryFinding(join, link, loadedLinks.get(join))] });
1528
+ }
1529
+ return { links };
1530
+ }
1531
+
1532
+ /**
1533
+ * A44's kept clause: a link that states geometry of its own. 🚨 The one shape
1534
+ * the parser reads in SILENCE. `readAttachment` returns from the `source`
1535
+ * branch at `SkeletonJson.js:586`, before `map.uvs` is touched at all, so
1536
+ * `uvs`, `triangles`, `vertices`, `hull` and `edges` written on a link are read
1537
+ * by nothing — and `setSourceMesh` then fills the attachment with the SOURCE's
1538
+ * arrays. The file says one mesh and every runtime draws another.
1539
+ */
1540
+ function linkGeometryFinding(join: string, link: RawLinkedMesh, drawn: MeshAttachment | undefined): string {
1541
+ const [skinName, slotName, placeholder] = join.split('\u0000');
1542
+ const at = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} placeholder ${JSON.stringify(placeholder)}`;
1543
+ const keys = link.geometry.map((key) => JSON.stringify(key)).join(', ');
1544
+ // Where the parser looks for `source`, with the two defaults spelled out:
1545
+ // an omitted `skin` is the DEFAULT skin rather than this attachment's own
1546
+ // (`:429`), and an omitted `slot` IS this attachment's own (`:573-579`).
1547
+ const where =
1548
+ `skin ${JSON.stringify(link.skin ?? 'default')}${link.skin === undefined ? ' (the default skin, because no "skin" was stated — never the skin this attachment is written in)' : ''} ` +
1549
+ `slot ${JSON.stringify(link.slot ?? slotName)}${link.slot === undefined ? ' (this attachment\'s own, because no "slot" was stated)' : ''}`;
1550
+ // What the author's own keys describe, printed only when both are
1551
+ // readable — the shape the file states, beside the shape it draws.
1552
+ const states =
1553
+ link.statedVertices === undefined || link.statedTriangles === undefined
1554
+ ? ''
1555
+ : ` (${link.statedVertices} vertices and ${link.statedTriangles} triangles)`;
1556
+ const loaded =
1557
+ drawn === undefined
1558
+ ? 'what it loaded is not shown here because the round trip produced no attachment for it (A00 owns that)'
1559
+ : `it loaded ${drawn.worldVerticesLength / 2} vertices and ${drawn.triangles.length / 3} triangles`;
1560
+ return (
1561
+ `${at} links to ${JSON.stringify(link.source)} and states ${keys}${states}, and a linked mesh has no geometry of ` +
1562
+ 'its own. The parser returns from the `source` branch before `readVertices` ' +
1563
+ `(\`SkeletonJson.ts:582-586\`), so ${link.geometry.length === 1 ? 'that key is' : 'those keys are'} read by ` +
1564
+ `nothing at all: what this attachment draws is the geometry of ${JSON.stringify(link.source)} in ${where}, and ` +
1565
+ `${loaded}. Remove ${link.geometry.length === 1 ? 'it' : 'them'}, or remove "source" and author this as a ` +
1566
+ 'mesh of its own.'
1567
+ );
1568
+ }
1569
+
1570
+ /** A transform timeline's six channels, in frame order, and the `to` kind each one is the mix of. */
1571
+ const TRANSFORM_MIXES = [
1572
+ ['mixRotate', ToRotate],
1573
+ ['mixX', ToX],
1574
+ ['mixY', ToY],
1575
+ ['mixScaleX', ToScaleX],
1576
+ ['mixScaleY', ToScaleY],
1577
+ ['mixShearY', ToShearY],
1578
+ ] as const;
1579
+
1580
+ /**
1581
+ * The motion spec's word for what a constraint timeline keys, off the
1582
+ * runtime's own `Property` name: `physicsConstraintWind` -> `wind`,
1583
+ * `pathConstraintMix` -> `mix`, `sliderTime` -> `time`, `ikConstraint` ->
1584
+ * `ik` — the name with the constraint kind taken off the front. Derived
1585
+ * rather than tabulated: a table here would be one more hand-kept list of the
1586
+ * runtime's enum.
1587
+ */
1588
+ function keyedWord(property: string, kind: string): string {
1589
+ const rest = property.replace(/^(ik|transform|path|physics|slider)(Constraint)?/, '');
1590
+ return rest === '' ? kind : rest.charAt(0).toLowerCase() + rest.slice(1);
1591
+ }
1592
+
1593
+ /**
1594
+ * The runtime's supply of `ConstraintFacts` (issue #1025, cut 4c-2): the
1595
+ * loaded constraints in update order, each as the bodies read it, and every
1596
+ * constraint timeline of every loaded animation in the order the runtime
1597
+ * built it — its kind and word off the runtime's own `Property` name, the
1598
+ * constraint it names, whom it writes (`unnamedPhysicsReach` for a physics
1599
+ * timeline naming none), its frames, every value a channel poses
1600
+ * (`curveChannelValues`, the Bézier samples the parser stored included), and,
1601
+ * on a physics timeline `PHYSICS_POSE_RULES` bounds, the pose field the
1602
+ * runtime's own `set` writes a keyed value into.
1603
+ */
1604
+ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeletonData']>): ConstraintFacts {
1605
+ const constraints: ConstraintEntry[] = data.constraints.map((c): ConstraintEntry => {
1606
+ // Taken structurally rather than through `ConstraintData<T, P>`, whose two
1607
+ // type arguments the runtime itself fills with `any` — which `src/` may not write.
1608
+ const runtimeClass = (c as { constructor: { name: string } }).constructor.name.replace(/Data$/, '');
1609
+ if (c instanceof PhysicsConstraintData) {
1610
+ const pose = c.setupPose;
1611
+ return {
1612
+ kind: 'physics',
1613
+ name: c.name,
1614
+ runtimeClass,
1615
+ physics: {
1616
+ bone: c.bone.name,
1617
+ boneLength: c.bone.length,
1618
+ components: { x: c.x, y: c.y, rotate: c.rotate, scaleX: c.scaleX, shearX: c.shearX },
1619
+ setup: { mix: pose.mix, massInverse: pose.massInverse, strength: pose.strength, damping: pose.damping },
1620
+ step: c.step,
1621
+ },
1622
+ };
1623
+ }
1624
+ if (c instanceof PathConstraintData) {
1625
+ const pose = c.setupPose;
1626
+ return { kind: 'path', name: c.name, runtimeClass, path: { bones: c.bones.map((b) => b.name), slot: c.slot.name, setup: { mixRotate: pose.mixRotate, mixX: pose.mixX, mixY: pose.mixY } } };
1627
+ }
1628
+ if (c instanceof SliderData) {
1629
+ const animation = c.animation;
1630
+ return {
1631
+ kind: 'slider',
1632
+ name: c.name,
1633
+ runtimeClass,
1634
+ slider: {
1635
+ animation: animation ? { name: animation.name, timelines: animation.timelines.length, duration: animation.duration } : null,
1636
+ bone: c.bone ? c.bone.name : null,
1637
+ loop: c.loop,
1638
+ scale: c.scale,
1639
+ mix: c.setupPose.mix,
1640
+ },
1641
+ };
1642
+ }
1643
+ if (c instanceof IkConstraintData) {
1644
+ const secondAncestors: string[] = [];
1645
+ if (c.bones.length === 2) for (let at = c.bones[1].parent; at !== null; at = at.parent) secondAncestors.push(at.name);
1646
+ return { kind: 'ik', name: c.name, runtimeClass, ik: { bones: c.bones.map((b) => b.name), target: c.target.name, mix: c.setupPose.mix, secondAncestors } };
1647
+ }
1648
+ if (c instanceof TransformConstraintData) {
1649
+ const pose = c.setupPose;
1650
+ return {
1651
+ kind: 'transform',
1652
+ name: c.name,
1653
+ runtimeClass,
1654
+ transform: {
1655
+ bones: c.bones.map((b) => b.name),
1656
+ mixes: TRANSFORM_MIXES.map(([field, kind]) => (c.properties.some((from) => from.to.some((to) => to instanceof kind)) ? { field, setup: pose[field] } : null)),
1657
+ },
1658
+ };
1659
+ }
1660
+ throw new Error(`a constraint of class ${runtimeClass} is none of the five kinds`);
1661
+ });
1662
+ const physicsData = data.constraints.filter((one) => one instanceof PhysicsConstraintData);
1663
+ const timelines: ConstraintTimelineFact[] = [];
1664
+ for (const animation of data.animations) {
1665
+ for (const timeline of animation.timelines) {
1666
+ if (!isConstraintTimeline(timeline)) continue;
1667
+ const property = String(Property[Number(timeline.propertyIds[0].split('|')[0])] ?? timeline.propertyIds[0]);
1668
+ const kind = (/^(ik|transform|path|physics|slider)/.exec(property)?.[1] ?? '') as ConstraintFacts['constraints'][number]['kind'];
1669
+ const word = keyedWord(property, kind);
1670
+ const reset = timeline instanceof PhysicsConstraintResetTimeline;
1671
+ const frames: Array<{ time: number; value: number }> = [];
1672
+ if (!reset) {
1673
+ const entries = timeline.getFrameEntries();
1674
+ for (let i = 0; i < timeline.frames.length; i += entries) frames.push({ time: timeline.frames[i], value: timeline.frames[i + 1] });
1675
+ }
1676
+ const rule = timeline instanceof PhysicsConstraintTimeline ? physicsRuleFor(word) : undefined;
1677
+ const probe = new PhysicsConstraintPose();
1678
+ timelines.push({
1679
+ animation: animation.name,
1680
+ kind,
1681
+ word,
1682
+ constraint: timeline.constraintIndex,
1683
+ reach: timeline.constraintIndex >= 0 ? [timeline.constraintIndex] : unnamedPhysicsReach(timeline, physicsData).map((one) => data.constraints.indexOf(one)),
1684
+ frames,
1685
+ channelValues: (channel) => (reset ? [] : curveChannelValues(timeline as CurveTimeline & ConstraintTimeline, channel)),
1686
+ ...(rule === undefined || !(timeline instanceof PhysicsConstraintTimeline)
1687
+ ? {}
1688
+ : {
1689
+ posed: (value: number): number => {
1690
+ timeline.set(probe, value);
1691
+ return probe[rule.field];
1692
+ },
1693
+ }),
1694
+ });
1695
+ }
1696
+ }
1697
+ const pathSlots: string[] = [];
1698
+ for (const skin of data.skins) {
1699
+ for (const entry of skin.getAttachments()) {
1700
+ if (!(entry.attachment instanceof PathAttachment)) continue;
1701
+ const name = data.slots[entry.slotIndex].name;
1702
+ if (!pathSlots.includes(name)) pathSlots.push(name);
1703
+ }
1704
+ }
1705
+ return { animations: data.animations.length, constraints, timelines, pathSlots };
1706
+ }
1707
+
1708
+ /**
1709
+ * The runtime's supply of `ConstraintTargetFacts` (issue #1025, cut 4c-5):
1710
+ * A34's walk of the raw JSON, as A34 always walked it — the `constraints`
1711
+ * array's objects, then each animation (`Object.entries`, the file's order)
1712
+ * that is an object, its `ik` and `transform` groups (each entry ONE key
1713
+ * array) and its `path`, `physics` and `slider` groups (each entry an object
1714
+ * of named key arrays, or a bare value), each where the group is an object.
1715
+ * `reach` builds the timeline the parser builds for a physics timeline NAME
1716
+ * (`unnamedPhysicsTimeline`) and asks it of the file's physics constraint
1717
+ * objects (`unnamedPhysicsReach`). The model side's supply is
1718
+ * `./assertions/model/constraint_targets.ts`.
1719
+ */
1720
+ export function rawConstraintTargets(raw: Json): ConstraintTargetFacts {
1721
+ const objects = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter((entry): entry is Json => isObj(entry));
1722
+ const constraints = objects.map((entry) => ({ name: typeof entry.name === 'string' ? entry.name : null, type: String(entry.type), spelled: String(entry.name) }));
1723
+ const rawPhysics = objects.filter((entry) => entry.type === 'physics');
1724
+ const keyArray = (timeline: string, keys: unknown): TargetKeyArray => ({ timeline, keys: Array.isArray(keys) ? { count: keys.length } : { spelled: `${JSON.stringify(keys)}` } });
1725
+ let animations: TargetAnimation[] | null = null;
1726
+ if (isObj(raw.animations)) {
1727
+ animations = [];
1728
+ for (const [name, anim] of Object.entries(raw.animations)) {
1729
+ if (!isObj(anim)) continue;
1730
+ const groups: TargetAnimation['groups'][number][] = [];
1731
+ for (const group of ['ik', 'transform'] as const) {
1732
+ if (!isObj(anim[group])) continue;
1733
+ groups.push({ group, entries: Object.entries(anim[group] as Json).map(([target, keys]) => ({ name: target, bare: null, keyArrays: [keyArray('', keys)] })) });
1734
+ }
1735
+ for (const group of NAMED_TIMELINE_GROUPS) {
1736
+ if (!isObj(anim[group])) continue;
1737
+ groups.push({
1738
+ group,
1739
+ entries: Object.entries(anim[group] as Json).map(([target, timelines]) =>
1740
+ isObj(timelines) ? { name: target, bare: null, keyArrays: Object.entries(timelines).map(([timeline, keys]) => keyArray(timeline, keys)) } : { name: target, bare: JSON.stringify(timelines), keyArrays: [] },
1741
+ ),
1742
+ });
1743
+ }
1744
+ animations.push({ name, groups });
1745
+ }
1746
+ }
1747
+ return {
1748
+ constraints,
1749
+ groupsByAnimation: animations,
1750
+ reach: (name) => {
1751
+ const timeline = unnamedPhysicsTimeline(name);
1752
+ if (timeline === null) return null;
1753
+ return { resets: timeline instanceof PhysicsConstraintResetTimeline, reached: unnamedPhysicsReach(timeline, rawPhysics).map((one) => String(one.name)) };
1754
+ },
1755
+ };
1756
+ }
1757
+
1758
+ /**
1759
+ * The runtime's supply of `SliderCompositionFacts` (issue #1025, cut 4c-5):
1760
+ * the loaded sliders in the `constraints` array's order, and each animation's
1761
+ * loaded timelines in the order the runtime built them, each with its class
1762
+ * name and its `propertyIds` — spelled as the facts spell an id (the property's
1763
+ * name off `Property`, the index after it as loaded, and a deform or sequence
1764
+ * timeline's attachment OBJECT replaced by the address a skin files it under,
1765
+ * first filing kept, as `spineSequenceFacts` maps it) — and the sentence's
1766
+ * words for each, `describe` as A40 always wrote it. `behaviour` is today's
1767
+ * probe, `timelineAddBehaviour`. The model side's supply is
1768
+ * `./assertions/model/slider_composition.ts`.
1769
+ */
1770
+ export function spineSliderComposition(data: ReturnType<SkeletonJson['readSkeletonData']>): SliderCompositionFacts {
1771
+ let addressOf: Map<object, string> | null = null;
1772
+ const address = (attachment: object): string => {
1773
+ if (addressOf === null) {
1774
+ addressOf = new Map();
1775
+ for (const skin of data.skins) {
1776
+ for (const entry of skin.getAttachments()) {
1777
+ if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
1778
+ }
1779
+ }
1780
+ }
1781
+ return addressOf.get(attachment) ?? '';
1782
+ };
1783
+ /** What a property id points at, in the words the rig spec uses — A40's `describe`. */
1784
+ const describe = (timeline: Timeline, id: string): string => {
1785
+ const property = Property[Number(id.split('|')[0])] ?? id;
1786
+ if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
1787
+ if (timeline instanceof DeformTimeline) {
1788
+ return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
1789
+ }
1790
+ if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
1791
+ if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
1792
+ return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
1793
+ }
1794
+ return `the skeleton's ${property}`;
1795
+ };
1796
+ /** 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. */
1797
+ const idOf = (timeline: Timeline, id: string): string => {
1798
+ const [head, ...rest] = id.split('|');
1799
+ const named = [Property[Number(head)] ?? head, ...rest];
1800
+ if (timeline instanceof DeformTimeline || timeline instanceof RuntimeSequenceTimeline) named[2] = address(timeline.attachment);
1801
+ return named.join('|');
1802
+ };
1803
+ const timelinesOf = (animation: Animation): SliderTimelineFact[] =>
1804
+ animation.timelines.map((timeline) => ({
1805
+ runtimeClass: timeline.constructor.name,
1806
+ properties: timeline.propertyIds.map((id) => ({ id: idOf(timeline, id), property: Property[Number(id.split('|')[0])] ?? id, names: describe(timeline, id) })),
1807
+ }));
1808
+ const sliders: SliderFact[] = [];
1809
+ data.constraints.forEach((c, index) => {
1810
+ if (!(c instanceof SliderData)) return;
1811
+ sliders.push({
1812
+ name: c.name,
1813
+ index,
1814
+ mix: c.setupPose.mix,
1815
+ additive: c.additive,
1816
+ skinRequired: c.skinRequired,
1817
+ skins: data.skins.filter((skin) => skin.constraints.includes(c)).map((skin) => skin.name),
1818
+ animation: c.animation === null ? null : { name: c.animation.name, timelines: timelinesOf(c.animation) },
1819
+ });
1820
+ });
1821
+ return {
1822
+ sliders,
1823
+ behaviour: (animationName, at) => {
1824
+ const timeline = data.findAnimation(animationName)?.timelines[at];
1825
+ if (timeline === undefined) throw new Error(`internal: animation "${animationName}" has no timeline ${at}`);
1826
+ return timelineAddBehaviour(data, timeline);
1827
+ },
1828
+ };
1829
+ }
1830
+
1831
+ /**
1832
+ * The facts cut 4c-2's bodies read, as `validate()` supplies them from a pair
1833
+ * spine-core loads — or `null` when the load throws (A00's failure). For the
1834
+ * selftest and `tools/verdict_gate.ts`, which compare them with the model
1835
+ * side's fact by fact, where a verdict line would hide a difference.
1836
+ */
1837
+ export function runtimeRigFacts(skeletonText: string, atlasText: string): { meshes: MeshFacts; polygons: PolygonFacts; links: LinkFacts; constraints: ConstraintFacts } | null {
1838
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1839
+ let raw: Json;
1840
+ try {
1841
+ raw = JSON.parse(skeletonText) as Json;
1842
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
1843
+ } catch {
1844
+ return null;
1845
+ }
1846
+ return { meshes: spineMeshFacts(data, raw), polygons: spinePolygonFacts(data, raw), links: spineLinkFacts(data, raw), constraints: spineConstraintFacts(data) };
1847
+ }
1848
+
1849
+ /**
1850
+ * Cut 4c-3's facts (issue #1025) as `validate()` supplies them from a pair
1851
+ * spine-core loads — A39's survey, A09's durations, A43's tint and A46's series
1852
+ * — or `null` when the load throws. For the selftest's `VF12` and
1853
+ * `tools/verdict_gate.ts`, which hand them to the bodies and ask the model
1854
+ * side's suppliers the same questions, value by value.
1855
+ */
1856
+ /**
1857
+ * The runtime's supply of A10's facts (issue #1025, cut 4c-5a): every pose A10
1858
+ * reads, posed by spine-core exactly as A10 always posed it.
1859
+ *
1860
+ * - The setup pose: `setupPose()`, `update(0)`,
1861
+ * `updateWorldTransform(Physics.reset)`, no animation set.
1862
+ * - Each animation's walk: `setAnimation(0, name, true)` — a LOOPING track —
1863
+ * over a fresh skeleton posed as above, then per step `AnimationState.update
1864
+ * (step)`, `apply`, `Skeleton.update(step)`, `updateWorldTransform
1865
+ * (Physics.update)`, one pose read after each.
1866
+ * - A bone's mode at a key: a fresh, non-looping track, the setup pose reset,
1867
+ * the track stepped to the key's time and applied, then `update` and
1868
+ * `updateWorldTransform(Physics.update)` by the same time.
1869
+ *
1870
+ * A pose is read as `posedNumbersOf` reads it for `render` (every bone's world
1871
+ * transform, every shown region's and mesh's world vertices), with each bone's
1872
+ * `appliedPose.inherit` — `null` where it is a value `updateWorldTransform`'s
1873
+ * switch has a case for, read off the runtime's own enum — and every slot's
1874
+ * light and dark colour. The model side's supply is
1875
+ * `./assertions/model/stepped_poses.ts`.
1876
+ */
1877
+ export function spineSteppedPoses(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SteppedPoseFacts {
1878
+ /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
1879
+ const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
1880
+ const frameOf = (skeleton: Skeleton): SteppedFrame => {
1881
+ const { bones, drawn } = posedNumbersOf(skeleton);
1882
+ return {
1883
+ bones: bones.map((b, i) => {
1884
+ const inherit = skeleton.bones[i].appliedPose.inherit;
1885
+ return { ...b, inherit: isMode(inherit) ? null : String(inherit) };
1886
+ }),
1887
+ drawn,
1888
+ slots: skeleton.slots.map((slot) => {
1889
+ const c = slot.appliedPose.color;
1890
+ const d = slot.appliedPose.darkColor;
1891
+ 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) };
1892
+ }),
1893
+ };
1894
+ };
1895
+ return {
1896
+ boneCount: data.bones.length,
1897
+ steppedAnimations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration })),
1898
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1899
+ hasBone: (name) => Boolean(data.findBone(name)),
1900
+ posedInherit: (animName, boneName, time) => {
1901
+ const skeleton = new Skeleton(data);
1902
+ const state = new AnimationState(new AnimationStateData(data));
1903
+ state.setAnimation(0, animName, false);
1904
+ skeleton.setupPose();
1905
+ skeleton.update(0);
1906
+ skeleton.updateWorldTransform(Physics.reset);
1907
+ state.update(time);
1908
+ state.apply(skeleton);
1909
+ skeleton.update(time);
1910
+ skeleton.updateWorldTransform(Physics.update);
1911
+ const bone = skeleton.findBone(boneName);
1912
+ if (bone === null) return undefined;
1913
+ const posed = bone.appliedPose.inherit;
1914
+ return isMode(posed) ? null : String(posed);
1915
+ },
1916
+ statedInherit: (name) => {
1917
+ const rawBone = Array.isArray(raw?.bones) ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === name) : undefined;
1918
+ return `${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}`;
1919
+ },
1920
+ setup: () => {
1921
+ const atRest = new Skeleton(data);
1922
+ atRest.setupPose();
1923
+ atRest.update(0);
1924
+ atRest.updateWorldTransform(Physics.reset);
1925
+ return frameOf(atRest);
1926
+ },
1927
+ walk: (animName, step, frames) => {
1928
+ const skeleton = new Skeleton(data);
1929
+ const state = new AnimationState(new AnimationStateData(data));
1930
+ state.setAnimation(0, animName, true);
1931
+ skeleton.setupPose();
1932
+ skeleton.update(0);
1933
+ skeleton.updateWorldTransform(Physics.reset);
1934
+ const out: SteppedFrame[] = [];
1935
+ for (let i = 0; i < frames; i++) {
1936
+ state.update(step);
1937
+ state.apply(skeleton);
1938
+ skeleton.update(step);
1939
+ skeleton.updateWorldTransform(Physics.update);
1940
+ out.push(frameOf(skeleton));
1941
+ }
1942
+ return out;
1943
+ },
1944
+ };
1945
+ }
1946
+
1947
+ export function runtimePosedFacts(skeletonText: string, atlasText: string): { deformSurvey: DeformSurveyFacts; animationDurations: AnimationDurationFacts; twoColour: TwoColourFacts; sequences: SequenceFacts; steppedPoses: SteppedPoseFacts; boneTimelines: BoneTimelineFacts } | null {
1948
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1949
+ let raw: Json;
1950
+ try {
1951
+ raw = JSON.parse(skeletonText) as Json;
1952
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
1953
+ } catch {
1954
+ return null;
1955
+ }
1956
+ return { deformSurvey: spineDeformSurvey(data), animationDurations: spineAnimationDurations(data), twoColour: spineTwoColourFacts(raw, data), sequences: spineSequenceFacts(raw, data), steppedPoses: spineSteppedPoses(raw, data), boneTimelines: rawBoneTimelines(raw) };
1957
+ }
1958
+
1959
+ /**
1960
+ * Cut 4c-5's facts (issue #1025) as `validate()` supplies them from a pair
1961
+ * spine-core loads — A40's sliders and A34's constraint groups, with the
1962
+ * constraint facts A40 reads beside them — or `null` when the load throws.
1963
+ * For the selftest's `VF14` and `tools/verdict_gate.ts`, which hand them to
1964
+ * the bodies and ask the model side's suppliers the same questions.
1965
+ */
1966
+ export function runtimeCut4c5Facts(skeletonText: string, atlasText: string): { sliderComposition: SliderCompositionFacts; constraintTargets: ConstraintTargetFacts; constraints: ConstraintFacts } | null {
1967
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1968
+ let raw: Json;
1969
+ try {
1970
+ raw = JSON.parse(skeletonText) as Json;
1971
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
1972
+ } catch {
1973
+ return null;
1974
+ }
1975
+ return { sliderComposition: spineSliderComposition(data), constraintTargets: rawConstraintTargets(raw), constraints: spineConstraintFacts(data) };
1976
+ }
1977
+
1978
+ /** 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. */
1979
+ function once<T>(supply: () => T): () => T {
1980
+ let held: { value: T } | null = null;
1981
+ return () => {
1982
+ held ??= { value: supply() };
1983
+ return held.value;
1984
+ };
1985
+ }
1986
+
1987
+ export function validate(input: ValidateInput): ValidateReport {
1988
+ const profile = input.profile;
1989
+ /** True when this profile's rulebook includes the policy layer. */
1990
+ const policy = profile === 'spine-html';
1991
+ // The harness — `check`, `fail`, `skip` and the lists they fill — is
1992
+ // `./assertions/harness.ts`'s since issue #1025, and the rule is the one it
1993
+ // always was: the model side runs the bodies that moved there in the same
1994
+ // harness, so a verdict cannot be decided two ways.
1995
+ const { failures, passed, skipped, profileSkipped, stats, fail, skip, check, verdicts } = verdictHarness(profile, ASSERTION_KIND, 'validate');
1996
+
1997
+ // -------------------------------------------------------------------------
1998
+ // Raw text / raw JSON assertions (they must run even if the parser is happy)
1999
+ // -------------------------------------------------------------------------
2000
+
2001
+ let raw: Json | null = null;
2002
+ try {
2003
+ raw = JSON.parse(input.skeletonText) as Json;
2004
+ } catch (err) {
2005
+ fail('A00_ROUNDTRIP_PARSE', `skeleton JSON is not parseable: ${(err as Error).message}`);
2006
+ }
2007
+
2008
+ // --- A07: atlas text shape ------------------------------------------------
2009
+ // Two traps, both measured: a region name is the RAW
2010
+ // line (only the page name is trimmed), and a blank line closes the page
2011
+ // block, so a blank line between a page header and its regions turns the
2012
+ // regions into pages.
2013
+ //
2014
+ // 🚨 Both traps are about a page BLOCK, and an atlas can honestly have none
2015
+ // (issue #608). A rig whose skins fill no slot with anything that needs art —
2016
+ // a hit-box skeleton carrying only a `boundingbox`, a clipping polygon, a
2017
+ // path — measures no pages, and `writeAtlasText` now spells that as the empty
2018
+ // file. Before this skip existed the walk below read it as one malformed page
2019
+ // block and printed two findings whose SUBJECTS DO NOT EXIST: there are no
2020
+ // consecutive blank lines in a file with no lines, and no last page block to
2021
+ // declare a region. `''.split('\n')` is `['']` rather than `[]`, so a
2022
+ // zero-byte atlas and a one-newline atlas both arrived here as a single blank
2023
+ // line, and the compiler's own output was refused by name for a shape nobody
2024
+ // had written.
2025
+ //
2026
+ // ⇒ Nothing to measure is a SKIP, and the sibling rule already settled this
2027
+ // for the same file: A08 skips with "this atlas declares no region and the
2028
+ // skeleton names no attachment that resolves through one". The protection is
2029
+ // not lost, because the case where an empty atlas MATTERS is an attachment
2030
+ // that wanted a region, and that is A08's failure by name (measured: a spec
2031
+ // built with `ingest --art none` and no `--atlas-in` fails A08 twice and A00
2032
+ // once, each naming the skin, the slot and the placeholder).
2033
+ const atlasLines = input.atlasText.replace(/\n$/, '').split('\n');
2034
+ const atlasPageLines = atlasLines.filter((line) => line.trim().length > 0).length;
2035
+ check('A07_ATLAS_TEXT_SHAPE', () => {
2036
+ if (atlasPageLines === 0) {
2037
+ // One of the exported reason constants, not a sentence of its own: the
2038
+ // static-rig suite's arithmetic (S50, #580) counts a rule as skipped
2039
+ // for want of a SUBJECT only when its reason is one of those constants.
2040
+ return skip('A07_ATLAS_TEXT_SHAPE', SKIP_NO_ATLAS_PAGE);
2041
+ }
2042
+ // A blank line at either END of the file is named as that end (issues #803,
2043
+ // #810), and a run of them is one finding stating its length. They are taken
2044
+ // off both ends before the walk below, so the walk sees the page blocks and
2045
+ // the blank lines between them and nothing else: a blank line after the last
2046
+ // region is not a page block, and "consecutive blank lines" and "the last
2047
+ // page block declares no region" are about blocks. `TextureAtlas` reads a
2048
+ // blank at either end as nothing (`TextureAtlas.js:98-100` skips the leading
2049
+ // run, `:116-118` ends a page block on each blank line), and rigc writes
2050
+ // neither (`writeAtlasText`, and `--atlas-in` through `canonicalAtlasShape`),
2051
+ // so a file that has one was written by something else and is refused.
2052
+ let leading = 0;
2053
+ while (leading < atlasLines.length && atlasLines[leading].trim().length === 0) leading++;
2054
+ if (leading > 0) {
2055
+ fail('A07_ATLAS_TEXT_SHAPE', `line 1: the file begins with ${leading === 1 ? 'a blank line' : `${leading} blank lines`}`);
2056
+ }
2057
+ let trailing = 0;
2058
+ while (trailing < atlasLines.length - leading && atlasLines[atlasLines.length - 1 - trailing].trim().length === 0) trailing++;
2059
+ const blockEnd = atlasLines.length - trailing;
2060
+ let expectPage = true;
2061
+ let sawRegionForPage = false;
2062
+ for (let i = leading; i < blockEnd; i++) {
2063
+ const line = atlasLines[i];
2064
+ if (line.trim().length === 0) {
2065
+ if (expectPage) fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: consecutive blank lines`);
2066
+ else if (!sawRegionForPage) {
2067
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: blank line before this page had any region`);
2068
+ }
2069
+ expectPage = true;
2070
+ sawRegionForPage = false;
2071
+ continue;
2072
+ }
2073
+ if (expectPage) {
2074
+ expectPage = false;
2075
+ continue; // page name line
2076
+ }
2077
+ if (line.includes(':')) continue; // key: value line
2078
+ // A bare non-key line is a region name, and it is used untrimmed.
2079
+ if (line !== line.trim()) {
2080
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: region name has stray whitespace: ${JSON.stringify(line)}`);
2081
+ }
2082
+ sawRegionForPage = true;
2083
+ }
2084
+ if (!sawRegionForPage) fail('A07_ATLAS_TEXT_SHAPE', 'the last page block declares no region');
2085
+ if (trailing > 0) {
2086
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${blockEnd + 1}: the file ends with ${trailing === 1 ? 'a blank line' : `${trailing} blank lines`}`);
2087
+ }
2088
+ });
2089
+
2090
+ // --- A08: every attachment path resolves to a region the atlas has -------
2091
+ //
2092
+ // 🚨 This runs on the RAW file, before the round trip, and that placement is
2093
+ // the whole of issue #589. Two of the three clauses below used to sit behind
2094
+ // A00 and could not be reached by any input: `lookup` was the path spine-core
2095
+ // had already resolved, and `AtlasAttachmentLoader.findRegion`
2096
+ // (`dist/AtlasAttachmentLoader.js:55-59`) throws
2097
+ // `Region not found in atlas: <path> (attachment: <name>)` for a path naming
2098
+ // no region and for one padded with whitespace alike, so the skeleton never
2099
+ // finished loading and A08's body never ran. Measured on three weakenings of
2100
+ // `examples/spineboy` × both profiles: every one printed A00 FAIL and A08
2101
+ // SKIP. An assertion that names the defect only when the defect is absent is
2102
+ // the silence this tool exists to convert.
2103
+ //
2104
+ // ⇒ The join is re-derived here from the raw JSON, where it is visible before
2105
+ // the loader is asked, so the miss is refused by its own sentence naming the
2106
+ // attachment, the placeholder and the path as THREE THINGS. The loader's
2107
+ // message names none of them: `(attachment: crosshair)` is the attachment's
2108
+ // `name`, never the placeholder or the skin, it never says which of the two
2109
+ // files moved, and for a padded path it prints the string unquoted —
2110
+ // `Region not found in atlas: crosshair (attachment: crosshair)` — where the
2111
+ // defect is literally invisible. `JSON.stringify` below is why A08 can show it.
2112
+ //
2113
+ // 🔒 The round trip is NOT suppressed by any of this, and that is deliberate:
2114
+ // it is the oracle, and gating it on rigc's own re-derivation would mean a
2115
+ // wrong join here could silence the parser rigc did not write. Because it
2116
+ // still runs, the two are a permanent two-sided cross-check — A08 refusing a
2117
+ // path A00 then loads, or A00 throwing `Region not found` over a green A08,
2118
+ // is a visible contradiction in one report. What A00 does instead of throwing
2119
+ // twice about one fact is defer: see its own body.
2120
+ //
2121
+ // 🗑️ A08 used to be MIXED: the join is validity, and a second clause gated on
2122
+ // `spine-html` required the skin entry's PLACEHOLDER to be spelled exactly
2123
+ // like the region it resolves to ("v0 requires them identical"). That clause
2124
+ // is retired (issue #574), and the reason is that the renderer it was profiled
2125
+ // under never performed the join it described.
2126
+ //
2127
+ // `spine-html@0.4.1` resolves art in two steps and the placeholder is in
2128
+ // neither. `DomTexture.js:78,102` builds the image map with
2129
+ // `put(atlasRegion.name, …)` over every region of the atlas, and
2130
+ // `SpineHtmlRenderer.js:172` reads it back as
2131
+ // `const regionImage = region && this.regionImages.get(region.name)`, where
2132
+ // `region` came off the attachment — which `AtlasAttachmentLoader` resolved
2133
+ // through `path`. Every published version of that renderer keys the same way
2134
+ // (checked 0.1.0 through 0.4.1, the whole series). Nothing in it reads an
2135
+ // attachment's name, let alone its placeholder.
2136
+ //
2137
+ // ⚠️ It was not merely inert, either: it refused rigs on both sides of the
2138
+ // convention it was written for. `path` exists precisely so a placeholder
2139
+ // may differ from the PNG basename (R5), and since issue #567 a placeholder
2140
+ // two named skins share is emitted as `<skin>/<placeholder>` with `path`
2141
+ // restated to the basename — the only spelling the Spine editor holds. Under
2142
+ // the old clause that shape, and any rig that merely named a part something
2143
+ // other than its placeholder, was red under `spine-html` while the editor
2144
+ // imported it and the renderer drew it.
2145
+ /**
2146
+ * Every attachment path A08 refused because the atlas holds no region of that
2147
+ * name — which is exactly the set `AtlasAttachmentLoader.findRegion` throws
2148
+ * on. A00 reads it so that its own row can point at A08 rather than restate
2149
+ * the miss in the loader's poorer words.
2150
+ */
2151
+ const pathsWithNoRegion = new Set<string>();
2152
+ check('A08_REGION_NAMES_MATCH_ATTACHMENTS', () => a08RegionNamesMatchAttachments(verdicts, spineRegionJoins(input.atlasText, raw), pathsWithNoRegion));
2153
+
2154
+ // --- A31: every draw-order offset lands on a real place -------------------
2155
+ //
2156
+ // 🚨 This one runs BEFORE the round trip, and with A08 above it that is now
2157
+ // two assertions that do so for a reason other than "the parser is happy
2158
+ // about it" — A08 because the loader refuses the file before it can name what
2159
+ // is wrong with it, this one because the loader does not come back. A draw-order
2160
+ // key whose offsets are not in ascending slot order does not load wrong — it
2161
+ // does not load at all. `readDrawOrder` (SkeletonJson.ts:1336-1374) walks a
2162
+ // forward-only cursor:
2163
+ //
2164
+ // while (originalIndex !== index) unchanged[unchangedIndex++] = originalIndex++;
2165
+ //
2166
+ // and an entry naming an EARLIER slot than the one before it makes that
2167
+ // condition unreachable, so the loader spins and grows an array until the
2168
+ // process dies. So the check has to happen first, and when it finds that shape
2169
+ // the round trip is not attempted at all — reported as such, not as a pass.
2170
+ //
2171
+ // The other two shapes are the format's usual silence. An offset that puts a
2172
+ // slot outside the array writes past the end, leaves a −1 hole in the
2173
+ // permutation, and the fill loop reads `unchanged[-1]` — `undefined` where a
2174
+ // slot index belongs, with nothing thrown. Two entries for one slot write
2175
+ // twice at one cursor position and the first move is simply lost.
2176
+ let drawOrderIsUnparseable: string | null = null;
2177
+ check('A31_DRAW_ORDER_OFFSETS_RESOLVE', () => {
2178
+ if (!raw) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
2179
+ if (!Array.isArray(raw.slots) || !isObj(raw.animations)) {
2180
+ return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton declares no slots or no animations');
2181
+ }
2182
+ const slotIndex = new Map<string, number>();
2183
+ (raw.slots as unknown[]).forEach((slot, i) => {
2184
+ if (isObj(slot) && typeof slot.name === 'string') slotIndex.set(slot.name, i);
2185
+ });
2186
+ const slotCount = (raw.slots as unknown[]).length;
2187
+ let sawATimeline = false;
2188
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
2189
+ if (!isObj(anim) || !Array.isArray(anim.drawOrder)) continue;
2190
+ sawATimeline = true;
2191
+ (anim.drawOrder as unknown[]).forEach((key, k) => {
2192
+ const at = `animation "${animName}" drawOrder key ${k}`;
2193
+ if (!isObj(key) || !Array.isArray(key.offsets)) return; // no offsets = setup order
2194
+ let previous = -1;
2195
+ for (const entry of key.offsets as unknown[]) {
2196
+ if (!isObj(entry) || typeof entry.slot !== 'string' || typeof entry.offset !== 'number') {
2197
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: an offset is not { slot: string, offset: number }`);
2198
+ continue;
2199
+ }
2200
+ const index = slotIndex.get(entry.slot);
2201
+ if (index === undefined) {
2202
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: slot "${entry.slot}" is not in the skeleton`);
2203
+ continue;
2204
+ }
2205
+ if (index <= previous) {
2206
+ const detail =
2207
+ `${at}: slot "${entry.slot}" is at index ${index}, after an entry at index ${previous} — ` +
2208
+ 'offsets must be in ascending slot order or the loader never finishes reading them';
2209
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', detail);
2210
+ drawOrderIsUnparseable ??= detail;
2211
+ continue;
2212
+ }
2213
+ previous = index;
2214
+ const landing = index + entry.offset;
2215
+ if (!Number.isInteger(entry.offset) || landing < 0 || landing >= slotCount) {
2216
+ fail(
2217
+ 'A31_DRAW_ORDER_OFFSETS_RESOLVE',
2218
+ `${at}: slot "${entry.slot}" is at index ${index} and offset ${entry.offset} puts it at ${landing}, ` +
2219
+ `outside the ${slotCount} slots`,
2220
+ );
2221
+ }
2222
+ }
2223
+ });
2224
+ }
2225
+ if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
2226
+ });
2227
+
2228
+ // --- A32: every event key fires a declared event, in order ----------------
2229
+ //
2230
+ // The event timeline's three failure modes, and only the first is loud:
2231
+ //
2232
+ // 1. **An undeclared name.** `findEvent` returns null and `readAnimation`
2233
+ // throws `Event not found` (SkeletonJson.ts:1244). A00 would catch it, but
2234
+ // as a parser message about a name with no context; this one says which
2235
+ // animation, which key, and what the skeleton does declare.
2236
+ // 2. **Times out of order.** `readAnimation` writes frame `i` from key `i` in
2237
+ // ARRAY order and never sorts, so a decreasing time builds an
2238
+ // `EventTimeline` whose frames run backwards. It loads clean, and the
2239
+ // firings behind the fold simply never come out. Equal times are fine —
2240
+ // two events on one frame is ordinary — so this is non-decreasing.
2241
+ // 3. **`volume`/`balance` on a silent event.** `:1254-1257` reads them only
2242
+ // inside `if (event.data.audioPath)`, so on an event with no `audio` they
2243
+ // are two numbers in the file that no runtime will ever read.
2244
+ //
2245
+ // It runs on the raw JSON rather than on the loaded data because the loaded
2246
+ // `Event` no longer remembers which fields the file wrote: an override that was
2247
+ // dropped and an override that matched the default are the same object.
2248
+ //
2249
+ // ✂️ Split per clause since issue #1025 (cut 4c-4): the third mode is the
2250
+ // rig's — a readable model document states a key's `volume` and the event's
2251
+ // `audio` — and its clause is the body's (`./assertions/bodies/a32.ts`); the
2252
+ // first two, with "no string name" and "a time that is not a finite number",
2253
+ // are states the document's reader refuses by name, so they stay here, in
2254
+ // `rawEventKeys`, and the body prints what they found at their place.
2255
+ check('A32_EVENT_KEYS_RESOLVE', () => a32EventKeysResolve(verdicts, rawEventKeys(raw)));
2256
+
2257
+ // --- A34: a constraint timeline aims at a constraint of that type ---------
2258
+ //
2259
+ // Five groups, in two shapes. `ik` and `transform` are ONE unnamed timeline
2260
+ // per constraint (`animations.<a>.ik.<name>` is the key array itself); `path`,
2261
+ // `physics` and `slider` put named timelines under the constraint
2262
+ // (`animations.<a>.path.<name>.position`). Both
2263
+ // shapes resolve the constraint by name AND by type —
2264
+ // `findConstraint(name, IkConstraintData)` returns null for a transform
2265
+ // constraint that happens to share the name, and `readAnimation` then throws
2266
+ // `IK Constraint not found`. That one is loud — A00 reports it, as a parser
2267
+ // message about a name with no context — so this assertion exists for the
2268
+ // second failure, which is silent:
2269
+ //
2270
+ // **An empty key array.** `let keyMap = constraintMap[0]; if (!keyMap)
2271
+ // continue;` — the group is read, the timeline is skipped, and nothing is
2272
+ // said. `"ik": { "leg-ik": [] }` is a timeline that does not exist, written
2273
+ // by a generator that thought it wrote one. Every one of the five groups has
2274
+ // that line.
2275
+ //
2276
+ // Reporting both from here also means a candidate with a misspelled constraint
2277
+ // gets told which constraints it does have, rather than being handed the
2278
+ // loader's own sentence.
2279
+ //
2280
+ // ⚠️ `physics` was NOT in the named-timeline loop until issue #593, and the
2281
+ // comment above it named the shape after the group it left out. It cost
2282
+ // nothing while the motion spec could state two physics timelines and both
2283
+ // came from `compileValueTrack`; a spec that can state six more is a spec
2284
+ // that can aim them at a constraint that is not there. The group list is a
2285
+ // constant now, and the message that names the timelines a group takes reads
2286
+ // them off `CHANNELS_BY_KIND` — it used to be a ternary over two groups,
2287
+ // which is a sentence that cannot be extended without being rewritten.
2288
+ //
2289
+ // ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
2290
+ // `./assertions/bodies/a34.ts`, over this file's constraint groups as the
2291
+ // raw JSON states them (`rawConstraintTargets`), and whom a physics timeline
2292
+ // naming no constraint reaches is still the parser's timeline asked of the
2293
+ // file's constraint objects, through the facts.
2294
+ check('A34_CONSTRAINT_TIMELINE_TARGETS', () =>
2295
+ raw ? a34ConstraintTimelineTargets(verdicts, rawConstraintTargets(raw)) : skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)'),
2296
+ );
2297
+
2298
+ // --- A35: a deform key's run lands inside the attachment it edits ---------
2299
+ //
2300
+ // 🚨 The nastiest silent failure in the animation half of this format. A deform
2301
+ // key is a sparse edit of a vertex array whose length comes from the attachment,
2302
+ // and the parser applies it with
2303
+ //
2304
+ // Utils.arrayCopy(verticesValue, 0, deform, start, verticesValue.length)
2305
+ //
2306
+ // into a `Float32Array`. Writing past the end of a typed array in JavaScript is
2307
+ // a **no-op** — no throw, no warning, no NaN — so a run one pair too long, or a
2308
+ // run aimed at an attachment with fewer vertices than the author thought, loses
2309
+ // its tail and deforms the rest correctly. The result looks nearly right, which
2310
+ // is worse than looking wrong.
2311
+ //
2312
+ // One more shape, equally quiet: a **non-finite value** in `vertices` reaches
2313
+ // `computeWorldVertices` and turns the vertex into NaN, which A10 would only
2314
+ // catch if the deformed slot's BONE went non-finite, and it does not.
2315
+ //
2316
+ // The length depends on the encoding and the two are the same split
2317
+ // `readVertices` makes: unweighted is one pair per vertex, weighted is one pair
2318
+ // per bone influence (`vertices.length / 3 * 2`). Deriving it here rather than
2319
+ // assuming either is the whole point — assuming would produce a confident,
2320
+ // wrong bound on half the meshes in the world.
2321
+ //
2322
+ // ## ⛔ What this rule must NOT require: pair alignment (issue #262)
2323
+ //
2324
+ // It used to refuse an odd `offset` and an odd-length run, on the reading that
2325
+ // "the array is x, y pairs, so a run has to start and end on one". The array is
2326
+ // pairs; the RUN is not, and the parser above is the whole argument — `start` is
2327
+ // a raw index into the deform array, `arrayCopy` copies `verticesValue.length`
2328
+ // floats from it, and the element-wise `deform[i] += vertices[i]` that follows
2329
+ // walks the entire array rather than the run. There is no pair arithmetic
2330
+ // anywhere in that path, so a run may begin and end mid-pair, and every slot the
2331
+ // run does not cover keeps the zero the fresh `Float32Array` came with — which
2332
+ // is the identity delta, i.e. exactly the setup vertex.
2333
+ //
2334
+ // 🚨 That made the rule refuse legitimate editor output. Spine trims leading
2335
+ // zeros off a delta run, and a trim lands wherever the zeros stop: the official
2336
+ // `spineboy-pro` export keys `hoverboard-board` (an unweighted mesh, 148 floats)
2337
+ // with `offset: 1` and 147 values, covering `1..148`. A35 was the only assertion
2338
+ // that failed it — `A00` round-trips it and `A10` steps it clean — and it is a
2339
+ // `'validity'` rule, so it fired in every profile and told an agent holding a
2340
+ // file that every Spine runtime plays to go and change correct data.
2341
+ //
2342
+ // ⭐ The bound that survives is the one the runtime actually has: the run has to
2343
+ // FIT (`offset + vertices.length <= deformLength`). That is the quiet defect the
2344
+ // rule exists for and it is unaffected by where the run starts.
2345
+ check('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', () => {
2346
+ if (!raw) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton JSON did not parse (A00 owns that failure)');
2347
+ if (!isObj(raw.animations)) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton declares no animations');
2348
+ /** skin name -> slot -> attachment, straight off the raw JSON. */
2349
+ const skins = new Map<string, Json>();
2350
+ for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
2351
+ if (isObj(skin) && typeof skin.name === 'string' && isObj(skin.attachments)) {
2352
+ skins.set(skin.name, skin.attachments as Json);
2353
+ }
2354
+ }
2355
+ let sawATimeline = false;
2356
+ let measured = 0;
2357
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
2358
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
2359
+ for (const [skinName, slotMap] of Object.entries(anim.attachments as Json)) {
2360
+ if (!isObj(slotMap)) continue;
2361
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
2362
+ if (!isObj(attMap)) continue;
2363
+ for (const [attName, timelines] of Object.entries(attMap)) {
2364
+ if (!isObj(timelines) || !Array.isArray(timelines.deform)) continue;
2365
+ sawATimeline = true;
2366
+ const at = `animation "${animName}" deform ${skinName}/${slotName}/${attName}`;
2367
+ const attachment = isObj(skins.get(skinName)?.[slotName])
2368
+ ? ((skins.get(skinName)![slotName] as Json)[attName] as unknown)
2369
+ : undefined;
2370
+ if (!isObj(attachment)) {
2371
+ fail(
2372
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
2373
+ `${at}: skin "${skinName}" has no attachment "${attName}" on slot "${slotName}"; the parser throws ` +
2374
+ '`Timeline attachment not found`',
2375
+ );
2376
+ continue;
2377
+ }
2378
+ const length = deformArrayLength(attachment);
2379
+ if (length === null) {
2380
+ // A linked mesh takes its geometry from another attachment, so the
2381
+ // raw file cannot state this one's length. Saying nothing beats
2382
+ // inventing a bound: an assertion with a default is how "nothing to
2383
+ // measure" becomes a measurement of the wrong thing.
2384
+ continue;
2385
+ }
2386
+ measured++;
2387
+ const keys = timelines.deform as unknown[];
2388
+ if (keys.length === 0) {
2389
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at}: the key array is empty; the parser skips the timeline in silence`);
2390
+ continue;
2391
+ }
2392
+ keys.forEach((key, k) => {
2393
+ if (!isObj(key)) {
2394
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: not an object`);
2395
+ return;
2396
+ }
2397
+ const vertices = key.vertices;
2398
+ if (vertices === undefined || vertices === null) return; // "back to setup" — nothing to fit
2399
+ if (!Array.isArray(vertices)) {
2400
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: vertices is ${JSON.stringify(vertices)}, not an array`);
2401
+ return;
2402
+ }
2403
+ const offset = key.offset === undefined ? 0 : key.offset;
2404
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
2405
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: offset is ${JSON.stringify(key.offset)}`);
2406
+ return;
2407
+ }
2408
+ // ⛔ No parity clause here, and the header says why: an odd `offset`
2409
+ // and an odd-length run are both what a trimmed editor export looks
2410
+ // like, and the parser has no pair arithmetic to be misaligned
2411
+ // against (#262).
2412
+ if (offset + vertices.length > length) {
2413
+ fail(
2414
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
2415
+ `${at} key ${k}: the run covers ${offset}..${offset + vertices.length} of a ${length}-long deform ` +
2416
+ 'array; everything past the end is copied into a Float32Array and dropped without a word',
2417
+ );
2418
+ }
2419
+ for (const n of vertices as unknown[]) {
2420
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
2421
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: the run holds a non-finite value ${JSON.stringify(n)}`);
2422
+ break;
2423
+ }
2424
+ }
2425
+ });
2426
+ }
2427
+ }
2428
+ }
2429
+ }
2430
+ if (!sawATimeline) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'no animation carries a deform timeline');
2431
+ if (measured === 0) {
2432
+ return skip(
2433
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
2434
+ 'every deform timeline here keys an attachment whose vertex count the raw file does not state (a linked mesh)',
2435
+ );
2436
+ }
2437
+ });
2438
+
2439
+ // --- A: the round trip ----------------------------------------------------
2440
+ // The two loaded objects come back OUT of the assertion rather than being
2441
+ // assigned into it. Everything below reads them, and a `let` written inside a
2442
+ // callback is a value the type checker cannot see being set: it narrows to
2443
+ // `null` at the first guard and to `never` inside it, so `atlas.pages` stops
2444
+ // type-checking while working perfectly at runtime.
2445
+ const roundTrip = check('A00_ROUNDTRIP_PARSE', () => {
2446
+ if (drawOrderIsUnparseable !== null) {
2447
+ throw new Error(`not attempted — the loader would not return: ${drawOrderIsUnparseable}`);
2448
+ }
2449
+ const parsedAtlas = new TextureAtlas(input.atlasText);
2450
+ const json = new SkeletonJson(new AtlasAttachmentLoader(parsedAtlas));
2451
+ try {
2452
+ return { atlas: parsedAtlas, data: json.readSkeletonData(JSON.parse(input.skeletonText)) };
2453
+ } catch (err) {
2454
+ // 📌 The round trip is still ATTEMPTED — always, and A08 above cannot
2455
+ // stop it (issue #589). What changes here is only what A00 SAYS when the
2456
+ // loader refuses a region A08 has already refused by name: it defers
2457
+ // instead of printing the same fact a second time, in words that name
2458
+ // neither the placeholder nor the skin and that cannot show whitespace.
2459
+ //
2460
+ // ⚠️ The deference is conditional on the two agreeing about the exact
2461
+ // path, so the case A08 is wrong about stays loud: a `Region not found`
2462
+ // throw over a path A08 did NOT refuse prints verbatim, and A08 refusing
2463
+ // a path the loader then resolves leaves a FAIL beside a green A00. This
2464
+ // is the one clause that would hide either, and it is written so it
2465
+ // cannot.
2466
+ const wanted = /^Region not found in atlas: (.*) \(attachment: .+\)$/.exec((err as Error).message);
2467
+ if (wanted !== null && pathsWithNoRegion.has(wanted[1])) {
2468
+ throw new Error(
2469
+ 'the loader refused the file at the first attachment path this atlas has no region for — ' +
2470
+ 'A08_REGION_NAMES_MATCH_ATTACHMENTS names it, with the skin, the slot and the placeholder that wanted it',
2471
+ );
2472
+ }
2473
+ throw err;
2474
+ }
2475
+ });
2476
+ const atlas: TextureAtlas | null = roundTrip?.atlas ?? null;
2477
+ const skeletonData: ReturnType<SkeletonJson['readSkeletonData']> | null = roundTrip?.data ?? null;
2478
+
2479
+ if (atlas) {
2480
+ stats.pages = atlas.pages.length;
2481
+ stats.regions = atlas.regions.length;
2482
+ }
2483
+ if (skeletonData) {
2484
+ stats.bones = skeletonData.bones.length;
2485
+ stats.slots = skeletonData.slots.length;
2486
+ stats.animations = skeletonData.animations.length;
2487
+ stats.version = skeletonData.version ?? '(none)';
2488
+ }
2489
+
2490
+ // --- A16: version label ---------------------------------------------------
2491
+ //
2492
+ // The label must be on the 4.3 line, and the line includes its pre-releases:
2493
+ // every one of the nine official example exports declares "4.3.75-beta", which
2494
+ // the original `/^4\.3(\.\d+)?$/` rejected. That made the first file of the
2495
+ // benchmark ladder fail on a cosmetic string. What the assertion is actually
2496
+ // for is the MAJOR.MINOR pair — a 4.2 or 5.x label is portable-fragile because
2497
+ // some runtimes refuse a version mismatch outright (spine-runtimes CHANGELOG
2498
+ // line 1678), while spine-ts stores the string and never compares it. So the
2499
+ // patch component and any pre-release suffix after it are free, and 4.2/5.x
2500
+ // stay rejected.
2501
+ check('A16_SKELETON_VERSION_4_3', () => {
2502
+ const declared = isObj(raw?.skeleton) ? (raw.skeleton as Json).spine : undefined;
2503
+ if (typeof declared !== 'string' || spineGeneration(declared) !== SPINE_4_3) {
2504
+ fail(
2505
+ 'A16_SKELETON_VERSION_4_3',
2506
+ `skeleton.spine is ${JSON.stringify(declared)}, expected 4.3, 4.3.<patch> or 4.3.<patch>-<suffix>`,
2507
+ );
2508
+ }
2509
+ });
2510
+
2511
+ // --- A01: no legacy top-level constraint arrays ---------------------------
2512
+ // 4.3 folds every constraint into one `constraints` array with a `type`.
2513
+ // A 4.1/4.2-shaped `physics` array loads clean and the constraint just
2514
+ // vanishes. ⭐ The list is `generation.ts`'s since #706, because `ingest` has
2515
+ // to count the same arrays in a file it did not emit, and two copies of five
2516
+ // names is how one of them comes to be four.
2517
+ check('A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', () => {
2518
+ for (const key of TOPLEVEL_CONSTRAINT_ARRAYS) {
2519
+ if (raw && key in raw) {
2520
+ fail(
2521
+ 'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS',
2522
+ `top-level "${key}" array present; 4.3 wants it inside "constraints" with type:"${key}"`,
2523
+ );
2524
+ }
2525
+ }
2526
+ });
2527
+
2528
+ // --- A02: no bone.transform key ------------------------------------------
2529
+ // 4.2 renamed it to `inherit`, and 4.3 kept that spelling; the old key loads
2530
+ // and silently falls back to Normal inheritance (case 6b). The key itself is `generation.ts`'s, for
2531
+ // A01's reason.
2532
+ check('A02_NO_BONE_TRANSFORM_KEY', () => {
2533
+ const bones = Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : [];
2534
+ for (const bone of bones) {
2535
+ if (isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone) {
2536
+ fail('A02_NO_BONE_TRANSFORM_KEY', `bone "${String(bone.name)}" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"`);
2537
+ }
2538
+ }
2539
+ });
2540
+
2541
+ // --- A12: no dark / two-colour tint --------------------------------------
2542
+ // Parsed, then silently ignored by spine-html.
2543
+ check('A12_NO_DARK_COLOR', () => a12NoDarkColor(verdicts, rawSkeletonRoster(raw), rawSlotTimelines(raw)));
2544
+
2545
+ // --- A05: curve arrays are 4 numbers per value channel --------------------
2546
+ check('A05_CURVE_ARRAY_LENGTH', () => {
2547
+ // Two clauses, so the SKIP needs both to be empty (#580). The vocabulary
2548
+ // clause below measures every timeline it is handed — an unchecked name is a
2549
+ // finding whether or not any key on it carries a curve — so a skeleton with
2550
+ // timelines and no curve at all has still been measured. What measures
2551
+ // nothing is a skeleton `walkTimelines` never calls back on.
2552
+ let timelines = 0;
2553
+ walkTimelines(raw, (path, kind, name, keys) => {
2554
+ timelines++;
2555
+ const table = CHANNELS_BY_KIND[kind];
2556
+ if (!(name in table)) {
2557
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: unchecked ${kind} timeline "${name}" — extend the validator`);
2558
+ return;
2559
+ }
2560
+ const channels = table[name];
2561
+ for (const key of keys) {
2562
+ if (!isObj(key) || !('curve' in key)) continue;
2563
+ const curve = key.curve;
2564
+ if (channels === null) {
2565
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: timeline "${name}" cannot carry a curve`);
2566
+ continue;
2567
+ }
2568
+ if (curve === 'stepped') continue;
2569
+ if (!Array.isArray(curve)) {
2570
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve is ${JSON.stringify(curve)}, expected "stepped" or an array`);
2571
+ continue;
2572
+ }
2573
+ if (curve.length !== channels * 4) {
2574
+ fail(
2575
+ 'A05_CURVE_ARRAY_LENGTH',
2576
+ `${path} (t=${String(key.time ?? 0)}): curve has ${curve.length} numbers, "${name}" needs ${channels} channels x 4 = ${channels * 4}`,
2577
+ );
2578
+ }
2579
+ for (const n of curve) {
2580
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
2581
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve holds a non-finite value ${JSON.stringify(n)}`);
2582
+ }
2583
+ }
2584
+ }
2585
+ });
2586
+ if (timelines === 0) return skip('A05_CURVE_ARRAY_LENGTH', SKIP_NO_TIMELINE);
2587
+ });
2588
+
2589
+ // -------------------------------------------------------------------------
2590
+ // Loaded-data assertions
2591
+ // -------------------------------------------------------------------------
2592
+
2593
+ const regionAttachments: RegionAttachment[] = [];
2594
+ const meshAttachments: MeshAttachment[] = [];
2595
+ const meshSlots = new Set<number>();
2596
+ /**
2597
+ * What cut 4c-2's bodies read (issue #1025), each supplied off the loaded
2598
+ * skeleton the first time a body asks for it — so a supplier that throws
2599
+ * throws inside the `check` that asked, as the body it feeds always did.
2600
+ * Every mesh's link is read off the raw JSON and joined by (skin, slot,
2601
+ * placeholder) rather than asked of the loaded object, because
2602
+ * `MeshAttachment.sourceMesh` is **private with no accessor**
2603
+ * (`MeshAttachment.d.ts:50`) and `as any` is not available in `src/`; the
2604
+ * join is the parser's own (`spineMeshFacts`, `spineLinkFacts`).
2605
+ */
2606
+ const loadedMeshFacts = once(() => spineMeshFacts(skeletonData as NonNullable<typeof skeletonData>, raw));
2607
+
2608
+ if (skeletonData) {
2609
+ const data = skeletonData as NonNullable<typeof skeletonData>;
2610
+ for (const skin of data.skins) {
2611
+ for (const entry of skin.getAttachments()) {
2612
+ const att = entry.attachment;
2613
+ if (att instanceof RegionAttachment) regionAttachments.push(att);
2614
+ else if (att instanceof MeshAttachment) {
2615
+ meshAttachments.push(att);
2616
+ meshSlots.add(entry.slotIndex);
2617
+ }
2618
+ }
2619
+ }
2620
+ const polygonFacts = once(() => spinePolygonFacts(data, raw));
2621
+ const linkFacts = once(() => spineLinkFacts(data, raw));
2622
+ const constraintFacts = once(() => spineConstraintFacts(data));
2623
+ stats.regionAttachments = regionAttachments.length;
2624
+ stats.meshAttachments = meshAttachments.length;
2625
+ // What the bodies that moved to `./assertions/bodies/` read of the skins —
2626
+ // the runtime's supply of `SkinEntryFacts`, in the loaded skins' own order
2627
+ // (issue #1025).
2628
+ const skinEntries = spineSkinEntries(data);
2629
+ // What the mesh rules that moved read of the skins (issue #1025, cut 4c-1) is the one family of the meshes since issue #1054, `loadedMeshFacts`.
2630
+
2631
+ // --- A03: every region has finite width/height (case 6c) ---------------
2632
+ check('A03_REGION_WIDTH_HEIGHT_FINITE', () => a03RegionWidthHeightFinite(verdicts, skinEntries));
2633
+
2634
+ // --- A04: mesh triangles + encoding coherence (case 6f) ----------------
2635
+ check('A04_MESH_TRIANGLES_AND_ENCODING', () => a04MeshTrianglesAndEncoding(verdicts, loadedMeshFacts()));
2636
+
2637
+ // --- A33: bounding boxes and clipping polygons hold a real polygon -------
2638
+ // The body, its three silent failures and the clauses that stayed here
2639
+ // (the vertex run's decode, `polygonRunFindings`) are
2640
+ // `./assertions/bodies/a33.ts` (issue #1025, cut 4c-2).
2641
+ check('A33_VERTEX_ATTACHMENT_GEOMETRY', () => a33VertexAttachmentGeometry(verdicts, polygonFacts()));
2642
+
2643
+ // --- A11 / A13 / A14: renderer + canvas budgets ----
2644
+ check('A11_NO_CLIPPING_ATTACHMENTS', () => a11NoClippingAttachments(verdicts, skinEntries));
2645
+ // 📐 The two numbers come from the rig spec's `invariants`, never from here.
2646
+ // A mesh budget is one consumer's frame time written down — the editor's own
2647
+ // example projects ship meshes many times denser and they are valid — so a
2648
+ // constant in the validator would fail correct foreign data in the name of
2649
+ // somebody else's canvas. A rig that declares no budget has nothing to be
2650
+ // measured against, and the assertion says so instead of inventing a wall.
2651
+ check('A13_MESH_BUDGET', () => a13MeshBudget(verdicts, loadedMeshFacts(), input));
2652
+ check('A14_NO_FULL_FRAME_MESH', () => a14NoFullFrameMesh(verdicts, loadedMeshFacts(), spineStage(data, input.modelText)));
2653
+
2654
+ // --- A15: idle must not key a mesh-driving bone (dirty-skip lever) -----
2655
+ //
2656
+ // 🔑 The rule assumes meshes are mostly static: the renderer it serves skips
2657
+ // redrawing a mesh nothing moved, and an `idle` keying one of its bones spends
2658
+ // that skip on every frame. A painting rig is the genre where the assumption
2659
+ // is false by design (issues #855, #858) — one illustration in layers, most
2660
+ // of them weighted meshes, and an `idle` whose job is to move them — so the
2661
+ // rig can say so in `invariants.idleDrivesMeshes`, and the rule then reports
2662
+ // what the declaration costs instead of refusing each bone.
2663
+ check('A15_IDLE_NO_MESH_BONE_KEYS', () => a15IdleNoMeshBoneKeys(verdicts, loadedMeshFacts(), spineAnimatedBones(raw), input));
2664
+
2665
+ // --- A20/A21/A22: the mesh checks the parser will never make ------------
2666
+ //
2667
+ // A mesh is the one attachment type where every mistake is silent. Bad
2668
+ // weights do not throw, they skew; a uv outside the region samples the
2669
+ // wrong pixels; and an unpinned rim moves the seam, which is the single
2670
+ // thing the whole generated-parts approach depends on not happening.
2671
+ // A20 and A21 read their meshes through `MeshFacts` since issue #1025
2672
+ // (cut 4c-2): what built a mesh is `./assertions/mesh_kinds.ts`, the
2673
+ // decoded weights `spineMeshFacts`, and both bodies `./assertions/bodies/`.
2674
+ check('A20_MESH_WEIGHTS_COHERENT', () => a20MeshWeightsCoherent(verdicts, loadedMeshFacts(), policy, input.rig));
2675
+
2676
+ check('A21_MESH_RIM_PINNED', () => a21MeshRimPinned(verdicts, loadedMeshFacts(), input.rig));
2677
+
2678
+ check('A22_MESH_UVS_IN_UNIT_RANGE', () => a22MeshUvsInUnitRange(verdicts, loadedMeshFacts()));
2679
+
2680
+ // --- A39: a deform key that turns a triangle inside out ------------------
2681
+ //
2682
+ // 🚨 The animation half of this format had NO geometric measurement at all.
2683
+ // `A35` measures a deform run's LENGTH and its finiteness, and is silent on
2684
+ // whether the numbers in it mean anything: a key whose offsets are the wrong
2685
+ // sign, the wrong magnitude, or geometrically degenerate is green. Issue
2686
+ // #296 is that asymmetry, measured — three builds of `gallery/portrait`, one
2687
+ // correct, one with a band inverted and one folded at 40°, gate green with
2688
+ // the same 26 PASS / 13 SKIP and a byte-identical `MESH` coverage line,
2689
+ // because that line reports the SETUP pose (docs/FACE.md §9.2).
2690
+ //
2691
+ // What this measures is the one deformed-geometry fault that has a
2692
+ // reference-free answer: a triangle whose winding REVERSES draws its texture
2693
+ // backwards, and a mesh with one in it has locally turned inside out.
2694
+ //
2695
+ // ## The frame, and why it is the posed one
2696
+ //
2697
+ // Both sides of the comparison are taken at the key's OWN time, with the
2698
+ // animation applied — the deformed mesh against the same posed bones with
2699
+ // the deform cleared. Holding the bones at SETUP instead was tried and is
2700
+ // wrong in principle: a weighted mesh's offsets are authored in bone space
2701
+ // against the pose they land in, so setup bones measure a pose that never
2702
+ // occurs. (Measured, on this corpus the two frames agree to ~0.001 px² —
2703
+ // the fold is in the deform data either way — but the principle decides it,
2704
+ // not the agreement.) Sharing the bones between the two sides is also what
2705
+ // makes a MIRRORED slot bone a non-event: a negative determinant flips every
2706
+ // triangle on both sides and cancels.
2707
+ //
2708
+ // 🚨 **And "applied" means applied the way the animation is reached** (issue
2709
+ // #407). An animation a slider applies is never played on a track — the dial
2710
+ // selects the time, so the key's time and the applied time are the same
2711
+ // number by construction — and posing it on a track while its own slider
2712
+ // applies it at the neutral is the same error as setup bones, one level up:
2713
+ // a frame no playthrough contains. It reported a fold on a correct rig, and
2714
+ // the slot-colour half of the neutral apply undid the very alpha-0 key the
2715
+ // exemption below reads. So the survey inverts the slider's own mapping and
2716
+ // drives its bone until the runtime selects this key's time; the frame it
2717
+ // used is on every `DEFORM` line and on the stats line here.
2718
+ //
2719
+ // ## ⚠️ Why this is `archetype` and not `validity`
2720
+ //
2721
+ // Because the issue's premise — "it has no legitimate counter-example" — is
2722
+ // false on this repository's own corpus, and that was found by running it:
2723
+ //
2724
+ // - `spineboy-pro`, an official Spine editor export, flips 1 of the 101
2725
+ // triangles of `hoverboard-board` at key 1 (area −31.53 → +8.48, 2.5e-3
2726
+ // of that mesh's largest triangle).
2727
+ // - `gallery/flex` flipped up to 7 of the 75 triangles of its `leaf`, and
2728
+ // that one WAS a defect — visible tearing at the gust peak. Repaired in
2729
+ // issue #313: its exemption is gone and it gates green here now, which
2730
+ // leaves the editor export as the only standing counter-example.
2731
+ //
2732
+ // A `validity` rule would therefore tell an author holding correct editor
2733
+ // output to go and change it, which is what issues #44 and #262 already
2734
+ // cost this file twice. `archetype` says what is true: *rigc's own
2735
+ // formations do not fold*, judged under the profile where rigc's own policy
2736
+ // lives, and never on a skeleton rigc did not compile.
2737
+ //
2738
+ // A magnitude threshold was considered as the way to keep it `validity` —
2739
+ // exempt spineboy's 2.5e-3 sliver, catch the rest — and declined: the
2740
+ // smallest genuine defect the corpus then held (`flex` at 5.4e-3) and that
2741
+ // sliver were within a factor of two, so the wall would have been calibrated
2742
+ // on two points and separated nothing. Repairing `flex` does not revive the
2743
+ // idea: it removes the only point that was on the other side of the wall.
2744
+ // `invariants.deformMayFold` is the escape hatch instead, because *the
2745
+ // author* knows whether the page is turning over — and as of #313 nothing in
2746
+ // this repository uses it.
2747
+ //
2748
+ // ## The keys it reads no winding off at all (issue #401)
2749
+ //
2750
+ // One whose slot draws **no pixels** at that key's own time: faded to alpha
2751
+ // exactly 0, or showing another attachment. The message below states the
2752
+ // harm as "draws its texture backwards there", and that sentence is false
2753
+ // when nothing of the mesh lands — so the key is measured, printed, counted
2754
+ // on the stats line and then passed over. A triangle that draws no pixels
2755
+ // cannot draw them backwards.
2756
+ //
2757
+ // 🚨 **Exactly 0, and per key.** At alpha 0.5 a reversed triangle is plainly
2758
+ // visible at half strength and this goes on refusing it, with the alpha in
2759
+ // the message; any floor above 0 would be this repository choosing a
2760
+ // visibility policy, which is the thing an archetype rule exists not to do.
2761
+ // And a slot keyed to 0 in ONE animation is still gated in every other,
2762
+ // because the measurement is of a time and not of a slot — which is the
2763
+ // whole difference between this and `deformMayFold`, and why widening that
2764
+ // field was the wrong fix: it would have bought the fade at the price of a
2765
+ // blind spot at every angle where the part is fully visible.
2766
+ //
2767
+ // ## And the times NO key lands on (issue #403)
2768
+ //
2769
+ // The keys are where the data is; they are not where the runtime is. A deform
2770
+ // inside its fold angle at every key can be past it in between, and the
2771
+ // exemption above makes that easy to build into rather than merely possible:
2772
+ // land the alpha-0 key ON the folding key and the frames just before it are
2773
+ // drawn, nearly folded and — until this — unmeasured. Measured on the turn
2774
+ // probe: eight reversed triangles at alpha 0.20, gating green.
2775
+ //
2776
+ // So the survey also scans every SPAN between two consecutive keys. Its
2777
+ // arithmetic is a closed form rather than a subdivision count (the derivation
2778
+ // is on `wrongSignFractions`), and what it names is then *measured* at that
2779
+ // real posed time, alpha included — so a span refusal below is the same
2780
+ // measurement as a key refusal, taken at a time no key lands on.
2781
+ //
2782
+ // ⭐ **A span refusal is suppressed when either of its own two keys is
2783
+ // already refused.** The span check exists to say what the keys cannot; when
2784
+ // a key has already said it, a second and third message about one defect is
2785
+ // noise, and the build is refused either way.
2786
+ //
2787
+ // ## Where the arithmetic lives
2788
+ //
2789
+ // In [`src/deformmeasure.ts`](src/deformmeasure.ts), not here — because issue
2790
+ // #316's `DEFORM` report block prints the same reversal count this assertion
2791
+ // refuses on, and two derivations of one number drift. The survey measures
2792
+ // every key of every timeline and every span between them; this reads the
2793
+ // reversals out of it and the report prints the rest. What stays here is the
2794
+ // SEVERITY and the exemption: the survey has no opinion about either, which
2795
+ // is what lets a report run it with no exemption at all.
2796
+ //
2797
+ // Since issue #1025 (cut 4c-3) "here" is `./assertions/bodies/a39.ts`, which
2798
+ // takes the survey as a fact: `spineDeformSurvey` hands it spine-core's, and
2799
+ // the model side the core's over the model document — the same body, and the
2800
+ // survey the two hand it held to one by `tools/survey_hashes.ts`.
2801
+ check('A39_DEFORM_KEEPS_TRIANGLE_WINDING', () => a39DeformKeepsTriangleWinding(verdicts, spineDeformSurvey(data), input.rig));
2802
+
2803
+ // --- A23, A36, A37, A47, A48: a constraint that does nothing, quietly -
2804
+ //
2805
+ // Their bodies, the reasoning each states and the one reading of "does an
2806
+ // animation switch this on" they share (`switchedOn`, which replaced the
2807
+ // loaded-timeline `keyedLive` that stood here) moved to
2808
+ // `./assertions/bodies/` and `./assertions/constraint_words.ts` with issue
2809
+ // #1025 (cut 4c-2): every clause is about the rig, and each reads
2810
+ // `ConstraintFacts`, which `spineConstraintFacts` supplies here.
2811
+ check('A23_PHYSICS_CONSTRAINT_EFFECTIVE', () => a23PhysicsConstraintEffective(verdicts, constraintFacts(), loadedMeshFacts()));
2812
+
2813
+ check('A36_PATH_CONSTRAINT_EFFECTIVE', () => a36PathConstraintEffective(verdicts, constraintFacts()));
2814
+
2815
+ check('A37_SLIDER_CONSTRAINT_EFFECTIVE', () => a37SliderConstraintEffective(verdicts, constraintFacts()));
2816
+
2817
+ // --- A47 / A48: an ik or a transform constraint muted for good ----------
2818
+ //
2819
+ // The question A23, A36 and A37 ask of their own kinds, asked of the two
2820
+ // kinds editor exports use most (issue #765): a constraint that rests muted
2821
+ // and that no animation switches on parses, sits in the update cache and
2822
+ // moves nothing. [measured] on generated fixtures, 61 steps at 60 fps: an ik
2823
+ // at `mix` 0 that nothing keys, one keyed to 0 only, a transform at every mix
2824
+ // 0 that nothing keys and one keyed to 0 only each pose every bone exactly
2825
+ // where the same rig with no constraint does (max |Δ| 0.000000), and all four
2826
+ // gated green with 0 failures before these two existed. What is live
2827
+ // (`ikLive`), which transform mix is read and the third door
2828
+ // (`invariants.consumerDrivenMix`) are argued where they now live,
2829
+ // `./assertions/constraint_words.ts` and `./assertions/bodies/a48.ts`.
2830
+ check('A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => a47IkConstraintNotMutedThroughout(verdicts, constraintFacts(), input.rig));
2831
+
2832
+ check('A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => a48TransformConstraintNotMutedThroughout(verdicts, constraintFacts(), input.rig));
2833
+
2834
+ // --- A40: two sliders on one property, and the later one erases the other -
2835
+ //
2836
+ // 🚨 The hole A37 leaves. Every clause above is INTRA-slider — it asks
2837
+ // whether one slider applies anything at all — so nothing in the gate had an
2838
+ // opinion about two of them meeting, which is the shape a parameter-driven
2839
+ // face IS: one slider per axis, all of them on the same bones.
2840
+ //
2841
+ // `Slider.update` ends with
2842
+ //
2843
+ // animation.apply(skeleton, p.time, p.time, data.loop, null, p.mix,
2844
+ // MixFrom.current, data.additive, false, true)
2845
+ //
2846
+ // and `additive` defaults to **false** (`SkeletonJson.js`: `getValue(map,
2847
+ // "additive", false)`). With `add` false and `alpha` 1 every value function
2848
+ // in `Animation.js` drops the pose it was handed — `getRelativeValue`
2849
+ // returns `setup + value`, `getAbsoluteValue` and `getScaleValue` return
2850
+ // `value` — so whatever an earlier slider wrote to that property is gone,
2851
+ // and the sliders run in the order the `constraints` array puts them in
2852
+ // (`Skeleton.updateCache` walks that array and each `Slider.sort` pushes
2853
+ // itself onto the update cache as it is reached).
2854
+ //
2855
+ // [measured, issue #402, and reproduced by the controls in `selftest.ts`]
2856
+ // Two dials on one bone contributing 7.50° and 18.75°: both at the default
2857
+ // pose the bone at **18.7498°** — the later one alone; the same pair with
2858
+ // the array order swapped poses **7.4999°** — the other one alone; both
2859
+ // `additive: true` pose **26.2497°**, the sum. On a three-axis face two axes
2860
+ // are silently dead, the rig builds, and every other assertion is green.
2861
+ //
2862
+ // ## ⚠️ Why this is `validity`
2863
+ //
2864
+ // Because the claim is Spine's own arithmetic and not this project's taste:
2865
+ // a skeleton shaped this way is wrong for every runtime that plays it, so a
2866
+ // `renderer` or `archetype` kind would exclude the rule from `--profile
2867
+ // spine` — the CLI default — and the rig the issue is about would build
2868
+ // green for exactly the stranger it was written for.
2869
+ //
2870
+ // The counter-question is the one issues #44 and #262 cost this file twice:
2871
+ // can correct editor output trip it? Three shapes could, and each is
2872
+ // excluded STRUCTURALLY rather than by a threshold:
2873
+ //
2874
+ // 1. **Authority below 1.** At `mix < 1` the apply is a lerp from the
2875
+ // current pose (`current + (value + setup - current) * alpha`), so the
2876
+ // earlier slider still contributes and a chain of partial mixes is a
2877
+ // legitimate — if order-dependent — weighting. A slider whose setup mix
2878
+ // is under 1, or whose `mix` any animation keys, is dropped before the
2879
+ // comparison. This assertion has no opinion below full authority.
2880
+ // 2. **A skin switch.** `Skeleton.updateCache` makes a `skinRequired`
2881
+ // constraint active only while `skin.constraints.includes(data)`, and a
2882
+ // skeleton wears one skin, so two sliders listed by disjoint skins can
2883
+ // never be active in the same frame and cannot erase each other.
2884
+ // 3. **Different properties.** The unit compared is spine-core's own
2885
+ // `Timeline.propertyIds`, so two sliders on one bone that key different
2886
+ // properties — a yaw that rotates and a lift that translates — are not
2887
+ // a finding, and a partial overlap is refused only on the properties
2888
+ // that actually overlap.
2889
+ //
2890
+ // What is left is not a judgement call. At full authority, with both sliders
2891
+ // active, there is no value of the erased slider's dial at which it changes
2892
+ // that property: it is dead weight on every frame. That is also why this
2893
+ // rule has no `invariants` escape hatch where A39 needs one — there is
2894
+ // nothing an author could be preserving.
2895
+ //
2896
+ // ⚠️ Unmeasured, and stated as such: no editor export in this repository
2897
+ // carries a slider AT ALL — it is 4.3's newest constraint — so unlike A39
2898
+ // the "correct editor output" question rests on the runtime's arithmetic
2899
+ // rather than on a counter-example anybody has held. If an export ever
2900
+ // trips it, this paragraph is the one to reread.
2901
+ //
2902
+ // ## The second clause: `additive: true` is not always available
2903
+ //
2904
+ // A slot colour, an attachment swap, a draw order, an ik mix and a path's
2905
+ // spacing IGNORE the `add` argument entirely (`RGBATimeline.apply1` takes it
2906
+ // and never reads it), so two sliders sharing one of those overwrite each
2907
+ // other whatever the flags say. Refusing that case too is what keeps this
2908
+ // message from teaching a fix that does not work: an author told to set
2909
+ // `additive: true` on a shared rgba would get a green gate over the same
2910
+ // dead axis.
2911
+ //
2912
+ // 🚨 **Which class is which is MEASURED, and reading the flag was wrong.**
2913
+ // This clause used to ask `Timeline.additive`, the runtime's own declaration
2914
+ // that a class supports additive application — and two classes declare
2915
+ // `false` and honour `add` anyway. `PathConstraintMixTimeline` and
2916
+ // `SliderTimeline` pass the argument straight through, so two additive
2917
+ // sliders keying one path constraint's `mix`, or one bone-less slider's
2918
+ // `time`, **do** compose as the plain sum, and this assertion refused them
2919
+ // by name with a sentence about the runtime the runtime does not perform
2920
+ // (issue #655; `PS143` poses all thirty spellings of the motion vocabulary
2921
+ // and `PS140` holds the `time` case to a grid). `timelineAddBehaviour`
2922
+ // above poses each shared timeline instead, so what is compared is what the
2923
+ // class does rather than what it says about itself.
2924
+ //
2925
+ // ## The events clause, removed rather than narrowed
2926
+ //
2927
+ // The same reading refused two sliders whose animations both fire events,
2928
+ // and that refusal was over a property no pose can distinguish: `Slider`
2929
+ // applies its animation with `firedEvents` **null**
2930
+ // (`Slider.js`: `animation.apply(skeleton, p.time, p.time, data.loop, null,
2931
+ // …)`), and `EventTimeline.apply` opens with `if (!firedEvents) return`, so
2932
+ // a slider fires no event at all. Neither slider has anything on that
2933
+ // property for the other to erase. The same is true of a physics `reset`
2934
+ // under a slider, where `lastTime === time` leaves its window empty.
2935
+ //
2936
+ // ⭐ Neither is named here, and that is the point: the probe's third state
2937
+ // is **a timeline that writes nothing at all**, so both fall out of one
2938
+ // measurement instead of two exceptions. A list of unobservable spellings
2939
+ // written into this file would have been the hand-kept table that produced
2940
+ // the defect above.
2941
+ //
2942
+ // ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
2943
+ // `./assertions/bodies/a40.ts`, over the sliders and their animations'
2944
+ // timelines (`spineSliderComposition`) and the constraint facts, and what a
2945
+ // timeline does with `add` is still this probe, `timelineAddBehaviour`,
2946
+ // asked by the body through the facts.
2947
+ check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => a40SlidersComposeOnASharedTarget(verdicts, spineSliderComposition(data), constraintFacts()));
2948
+
2949
+ // --- A42: a dial that drives a constraint the array already ran ---------
2950
+ //
2951
+ // 🚨 The hole A40 leaves, and it leaves it BY CONSTRUCTION. A40's population
2952
+ // is the sliders at full authority whose own `mix` nothing keys, so the one
2953
+ // pair this rule is about — a slider whose `mix` IS keyed — is excluded
2954
+ // before any timeline is looked at. Between them the two rules ask different
2955
+ // questions about the same array: A40 asks who writes a shared property
2956
+ // last, this asks whether anybody reads what was written at all.
2957
+ //
2958
+ // `Skeleton.updateCache` walks `constraints` in order and each constraint's
2959
+ // `sort` pushes itself onto the update cache as it is reached — `sortBone`
2960
+ // pushes bones and never a constraint — so the array IS the update order,
2961
+ // for every kind. Every constraint then opens its `update` by reading its
2962
+ // own applied pose: `Slider.update` takes `mix` and `time` off it before
2963
+ // applying its animation, `PhysicsConstraint.update` returns on `mix === 0`
2964
+ // before reading `inertia`, `wind` and the rest, and the ik, transform and
2965
+ // path constraints read their mixes the same way. So a slider that keys any
2966
+ // property of a constraint is read by that constraint only when it comes
2967
+ // LATER in the array; written the other way round the value lands in a pose
2968
+ // whose only reader has already run, and `Skeleton.updateWorldTransform`
2969
+ // opens the next frame by putting `Posed.resetConstrained` over it.
2970
+ //
2971
+ // [measured, issue #665] One dial and one constraint, in both array orders,
2972
+ // read at 6 positions of the dial. The constraint's own pose holds the same
2973
+ // ramp in BOTH orders — the write happens either way — and what the
2974
+ // constraint drives is dead in one of them:
2975
+ //
2976
+ // ik shin worldY 0.000e+0 against 1.662e+1 the other way round
2977
+ // transform shin world x-angle 0.000e+0 against 4.000e+1
2978
+ // path rider worldX 0.000e+0 against 1.620e+2
2979
+ // physics tip worldX 0.000e+0 against 4.256e+2, over 30 frames, so
2980
+ // the one kind with state of its own is not rescued by it
2981
+ // slider flag rotate 0.000e+0 against 4.000e+1
2982
+ //
2983
+ // ⭐ The runtime repairs this for BONES and not for constraints, which is
2984
+ // why an author cannot reason it out from the bone case. `Slider.sort`
2985
+ // clears `sorted` on every bone its animation keys and re-sorts them, so
2986
+ // those bones always update after the slider; for a constraint it calls
2987
+ // `skeleton.constrained(constraints[t.constraintIndex])`, which only swaps
2988
+ // the pose the constraint will be read through and moves nothing in the
2989
+ // update cache.
2990
+ //
2991
+ // 🔒 Keying its OWN `mix` or `time` is the same failure with the indices
2992
+ // equal, and it is the one A37 cannot see: A37 asks whether any animation
2993
+ // keys the slider's `mix` and the slider's own animation is one of them, so
2994
+ // a slider muted at setup that keys its own `mix` up reports green and is
2995
+ // dead forever — `update` returns on `mix === 0` before the animation that
2996
+ // would raise it is ever applied. Only a slider can reach that shape: the
2997
+ // constraint at the driver's own index IS the driver.
2998
+ //
2999
+ // ⚠️ `physics` `reset` is NOT in this population, and the measurement is why
3000
+ // the card that asked for "every constraint a slider's animation keys" is
3001
+ // refused here on one spelling. `PhysicsConstraintResetTimeline` writes no
3002
+ // pose at all — `constraint.reset()` sets fields on the constraint object,
3003
+ // which `resetConstrained` does not touch — and it fires only when a frame
3004
+ // time is CROSSED. A slider applies its animation with `p.time` as both
3005
+ // `lastTime` and `time`, so nothing is ever crossed: [measured] 0 calls to
3006
+ // `PhysicsConstraint.reset` at 6 dial readings x 4 frames in BOTH array
3007
+ // orders, against 1 call when the same animation is applied over a span.
3008
+ // Refusing it would name a reorder that repairs nothing, so the SKIP says
3009
+ // what was found instead.
3010
+ check('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', () => a42DrivenConstraintsUpdateAfterTheirDriver(verdicts, constraintFacts()));
3011
+
3012
+ // --- A38: a per-skin member list and its `skin: true` flag agree --------
3013
+ //
3014
+ // 🚨 Two halves of one switch, and either half alone is dead data in silence.
3015
+ // `Skeleton.updateCache` (`:191-217`) starts every bone `active` unless it is
3016
+ // `skinRequired`, then activates the current skin's `bones` **and their whole
3017
+ // ancestor chain**; a constraint is `active` unless `skinRequired`, and then
3018
+ // only while `skin.constraints.includes(data)`. So:
3019
+ //
3020
+ // * listed without `skin: true` — the object is active under every skin,
3021
+ // and the list changes nothing at all;
3022
+ // * `skin: true` and listed nowhere — the object is inactive under every
3023
+ // skin there is: a bone that never poses, a constraint that never runs.
3024
+ //
3025
+ // Both load. Both animate. Neither is what the author wrote, and no other
3026
+ // check in this file can see either one, because the artifact is internally
3027
+ // consistent — it is the PAIRING that is wrong.
3028
+ check('A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED', () => a38SkinMembersAreSkinRequired(verdicts, data));
3029
+
3030
+ // --- A09: compiled duration == declared duration (rule 4) --------------
3031
+ //
3032
+ // The body is `./assertions/bodies/a09.ts` since issue #1025 (cut 4c-3);
3033
+ // what it reads off the loaded skeleton is `spineAnimationDurations`.
3034
+ check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => a09AnimationDurationMatchesSpec(verdicts, spineAnimationDurations(data), input.declaredDurations));
3035
+
3036
+ // --- A10: step every animation and look for NaN ------------------------
3037
+ // The body is `./assertions/bodies/a10.ts` since issue #1025 (cut 4c-5a); what it
3038
+ // reads off the loaded skeleton is `spineSteppedPoses` (and the raw JSON's bone
3039
+ // timelines, `rawBoneTimelines`). The argument for each clause, as it stood
3040
+ // inside the body:
3041
+ //
3042
+ // Two clauses, and since issue #902 each is read on its own subject. The
3043
+ // SETUP POSE is posed from the bones, so it has something to read on any
3044
+ // skeleton that carries one; the STEPPED FRAMES are posed once per
3045
+ // animation, so a skeleton with none has nothing to step. This used to
3046
+ // skip the whole rule on the second fact alone, which was A15's pattern
3047
+ // (#580) for a rule whose only clause was the stepping — and #882 gave it
3048
+ // the setup clause without moving the skip. Measured on the static probe
3049
+ // with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
3050
+ // printed this rule as SKIP and the run green under both profiles, and
3051
+ // `render` refused the same file by the bone. A static rig's setup pose
3052
+ // is the whole of what it shows, so that was the one pose nothing read.
3053
+ //
3054
+ // ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
3055
+ // and A38 carry: the rule SKIPs only when neither clause has anything to
3056
+ // measure, the clause that ran decides PASS or FAIL, and the clause that
3057
+ // had nothing is named on the stats line (`nanStepping=skipped`, beside
3058
+ // `animations=0`) the way A47 names a constraint it did not measure. A
3059
+ // PASS row carries no detail, which is why the stats line is where.
3060
+ //
3061
+ // ⚠️ A09 does not follow, and that is its own name read at its word: a
3062
+ // declared duration is a fact about an animation and nothing else, so a
3063
+ // static rig still gives it nothing at all.
3064
+ //
3065
+ // -- the bone's inheritance mode, posed at every `inherit` key ---------
3066
+ //
3067
+ // 🚨 The one bone timeline whose value is a NAME, and the only one that
3068
+ // can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
3069
+ // through `Utils.enumValue`, which folds the first letter's case and
3070
+ // nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
3071
+ // `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
3072
+ // `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
3073
+ // and the bone keeps whatever world matrix it had — measured on a forged
3074
+ // two-bone chain: at the key the child's `a,b,c,d` equal the setup
3075
+ // Normal-mode pose to the last digit while the file says `noScale`. The
3076
+ // world position stays finite, so the loop below never saw it (#733).
3077
+ //
3078
+ // ⚠️ Posed AT each key rather than read off the stepping loop, because a
3079
+ // stepped value lives from its key to the next one and a sampling grid
3080
+ // can step over a short span entirely. What is judged is whether the
3081
+ // posed value IS a mode — which is this assertion's name exactly — and
3082
+ // not which mode: for any spelling the lookup resolves, the mode posed
3083
+ // is the mode written by construction of the same lookup, so an equality
3084
+ // here would be the parser agreeing with itself.
3085
+ //
3086
+ // -- the whole world transform, at the setup pose and every stepped frame
3087
+ //
3088
+ // 🚨 Read through `firstNonFinite` (`./nonfinite.ts`) over `posedNumbersOf`,
3089
+ // the scan `render` refuses on, and not a second opinion of it (issue #882). This loop read `worldX` and
3090
+ // `worldY` alone until then, and a bone at `rotation: 1e309` — in its setup
3091
+ // pose or as a `rotate` key — poses a finite position over a NaN `a`, `b`,
3092
+ // `c` and `d` whenever it has no child offset from it: measured on every
3093
+ // leaf bone of the three generated probes, 20 of 20 green through the whole
3094
+ // gate, and `render` then refused the same file by the bone. So the gate
3095
+ // said the skeleton was fine and the renderer said it was not, which is two
3096
+ // definitions of one word. The scan reads the six terms of every bone, then
3097
+ // the vertices of every region and mesh shown — because a bone can be
3098
+ // finite over a vertex that is not (a `scaleX` chain whose product stays
3099
+ // under the largest double while every corner of the child's region
3100
+ // passes it) — and names the bone or the vertex, the term and the frame.
3101
+ //
3102
+ // ⚠️ The setup pose is its own surface and is read as such: every frame
3103
+ // below is posed AFTER `state.apply`, so a bone the animation keys from
3104
+ // t=0 never shows its setup value to the loop, and a runtime that shows
3105
+ // the rig at rest does show it.
3106
+ //
3107
+ // 🔸 Posed once, before any animation is set, rather than once per
3108
+ // animation as it was until #902: `setAnimation` does not touch the
3109
+ // skeleton, so every animation's copy of this pose was the same pose, and
3110
+ // the first of them was the only one ever read.
3111
+ //
3112
+ // The setup half of the inherit-key clause above: a bone's own
3113
+ // `inherit` goes through the same lookup, and a miss there loads
3114
+ // `undefined` into the setup pose, which every frame copies. Only
3115
+ // reachable on a file rigc did not write — `parseRigSpec` refuses the
3116
+ // spelling.
3117
+ //
3118
+ // The other colour a slot poses, and it was outside this loop until
3119
+ // issue #690 for the reason every gap here has: nothing emitted one.
3120
+ // `null` is the ordinary case — a slot with no `dark` allocates no
3121
+ // dark colour at all — and is not a reading to make, so it is
3122
+ // skipped rather than treated as zero.
3123
+ //
3124
+ // The stepping half, and on a static rig there is nothing to step: the
3125
+ // setup pose is then the only frame the skeleton has, and the two readings
3126
+ // a stepped frame gets are made on it instead, so a colour or an
3127
+ // inheritance mode the rig shows at rest is not left to `render`.
3128
+ check('A10_NO_NAN_AFTER_STEPPING', () => a10NoNanAfterStepping(verdicts, rawBoneTimelines(raw), spineSteppedPoses(raw, data)));
3129
+
3130
+ // --- A43: the two-colour tint, read back off the runtime ----------------
3131
+ //
3132
+ // ⭐ **Why this is its own rule and not a clause on `A10`.** A10 steps every
3133
+ // animation already and now reads `darkColor` for NaN, which is squarely its
3134
+ // own name; what is below is an EQUALITY — the colour in the file against the
3135
+ // colour the runtime holds — and a failure of it printed under
3136
+ // `A10_NO_NAN_AFTER_STEPPING` would be a verdict whose name contradicts its
3137
+ // own detail. The rule this repository applies to a suite's summary figure
3138
+ // applies to an assertion's name: it may not say less than what it decides.
3139
+ //
3140
+ // 🚨 **And the subject is real silence, measured rather than supposed.** Three
3141
+ // shapes load with no complaint:
3142
+ //
3143
+ // 1. A `dark` the parser drops. `SkeletonJson` reads `getValue(slotMap,
3144
+ // "dark", null)` and then `if (dark)`, so `""` is discarded without a
3145
+ // word and the slot renders as though the field had never been written.
3146
+ // 2. A `dark` that is not six hex digits. `Color.setFromString` runs
3147
+ // `parseInt` over fixed offsets and stores whatever comes back, so
3148
+ // `"4020"` loads `b = NaN` — a colour that is neither the author's nor
3149
+ // an error.
3150
+ // 3. An `rgba2` (or, since issue #730, `rgb2`) timeline on a slot with no
3151
+ // `dark` at all. `Slot`'s
3152
+ // constructor allocates `SlotPose.darkColor` only `if
3153
+ // (data.setupPose.darkColor != null)`, and `RGBA2Timeline.apply1` then
3154
+ // writes `dark.r` unconditionally — so the file parses, and the first
3155
+ // `state.apply` throws `TypeError: null is not an object` in the
3156
+ // consumer's process. `compile.ts` refuses that pairing outright; this
3157
+ // is the same fact held against a skeleton the compiler never saw, and
3158
+ // it is a named failure here rather than A10's `threw:` line, which
3159
+ // names neither the slot nor the timeline.
3160
+ //
3161
+ // ⚠️ The hex is parsed HERE rather than through `Color.fromString`, and that
3162
+ // is the point of the clause: a check that read the required value out of the
3163
+ // same parser it is checking would agree with it whatever it did.
3164
+ //
3165
+ // The body is `./assertions/bodies/a43.ts` since issue #1025 (cut 4c-3);
3166
+ // what it reads off the skeleton JSON and the loaded skeleton is
3167
+ // `spineTwoColourFacts`.
3168
+ check('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', () => a43TwoColorTintLoadsAndPosesAsWritten(verdicts, spineTwoColourFacts(raw, data)));
3169
+
3170
+ // --- A45: the separable colour timelines own their channels ------------
3171
+ //
3172
+ // The body, and the argument for the rule, are `./assertions/bodies/a45.ts`
3173
+ // since issue #1025: the same body runs over the model document. What it
3174
+ // reads off spine-core's loaded skeleton is `spineSlotColourFacts`.
3175
+ check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten(verdicts, spineSlotColourFacts(raw, data)));
3176
+
3177
+ // --- A44: a linked mesh states no geometry of its own ------------------
3178
+ //
3179
+ // 🚨 The one shape the parser reads in SILENCE. `readAttachment` returns from
3180
+ // the `source` branch at `SkeletonJson.js:586`, before `map.uvs` is touched
3181
+ // at all, so `uvs`, `triangles`, `vertices`, `hull` and `edges` written on a
3182
+ // link are read by nothing — and `setSourceMesh` then fills the attachment
3183
+ // with the SOURCE's arrays. The file says one mesh and every runtime draws
3184
+ // another, which is why this is `validity` and not one renderer's policy.
3185
+ //
3186
+ // ⭐ **It is a separate assertion rather than a clause on A04, and the reason
3187
+ // is measurable both ways.** A04 reads the LOADED attachment —
3188
+ // `mesh.triangles`, `mesh.worldVerticesLength`, `mesh.vertices` — which on a
3189
+ // link are the source's after `setSourceMesh`: measured on a forged link
3190
+ // declaring 5 uvs and 3 triangles beside a 4-vertex source, A04 PASSED
3191
+ // having read 8 and 2, the source's own. The keys this rule is about are not
3192
+ // in the data A04 holds, so the clause would have had to reach for the raw
3193
+ // file, and a verdict line reading A04's name would then be naming a
3194
+ // measurement of the loaded geometry while deciding about file keys nothing
3195
+ // read. The SKIP is the sharper half: A04's subject is mesh attachments, so
3196
+ // on a rig with meshes and no link its subject is PRESENT and it passes —
3197
+ // there is no verdict left for "this rig has no link to measure", and a
3198
+ // clause that cannot report SKIP reports a pass for an absent subject, which
3199
+ // this repository already has a judgment about.
3200
+ //
3201
+ // ⚠️ The subject is the FILE's links and not the loaded ones. A link whose
3202
+ // region is missing loads as `null` and is in no skin (`A08` names it), so
3203
+ // walking the loaded attachments would let the whole rule vanish on exactly
3204
+ // the file that is already wrong.
3205
+ //
3206
+ // 🔒 Since issue #1025 (cut 4c-2) the roster — which links the file
3207
+ // declares, and the SKIP over none — is `./assertions/bodies/a44.ts`'s, and
3208
+ // the clause that refuses a link's own geometry stays here
3209
+ // (`linkGeometryFinding`): a link record has no geometry field, so the keys
3210
+ // exist only in the Spine text.
3211
+ check('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', () => a44LinkedMeshStatesNoGeometryOfItsOwn(verdicts, linkFacts()));
3212
+
3213
+ // --- A46: a numbered series shows the frame the file states ------------
3214
+ //
3215
+ // 🚨 Every way this goes wrong loads without a word (issue #729, measured on
3216
+ // spine-core 4.3.13): a `sequence` block with no `count` loads a series of
3217
+ // no region; a `setup` past the end is clamped to the last frame; a key's
3218
+ // `mode` outside the seven loads as `hold`; an `index` past the end is
3219
+ // clamped and a fraction truncated; an advancing mode at an effective delay
3220
+ // of 0 never advances; and a `sequence` timeline on an attachment with no
3221
+ // block steps a one-region series and shows that region under every mode.
3222
+ //
3223
+ // ⭐ Two halves, and the second is the one only posing can see. The first
3224
+ // reads each of those off the FILE and names the value. The second POSES
3225
+ // every key at sampled times — mid-frame, so a float32 key time cannot land
3226
+ // a sample on a frame boundary — and compares the region the slot shows
3227
+ // against the one the file's own statement gives: the frame the key's mode,
3228
+ // index and delay give (`frameOf` in the body), and the frame names
3229
+ // `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
3230
+ // read off the loaded timeline, so the check is not the runtime agreeing
3231
+ // with itself.
3232
+ //
3233
+ // ⚠️ A sample where the slot shows some other attachment is not compared —
3234
+ // `applyToSlot` returns there, and a series that is hidden is not a series
3235
+ // showing the wrong frame. A timeline with no comparable sample at all is
3236
+ // counted in `stats.sequenceSamplesUnshown` rather than failed.
3237
+ //
3238
+ // The body is `./assertions/bodies/a46.ts` since issue #1025 (cut 4c-3);
3239
+ // what it reads off the skeleton JSON and the loaded skeleton is
3240
+ // `spineSequenceFacts`, which answers an attachment by its address.
3241
+ check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => a46SequenceAttachmentsShowTheFrameTheFileStates(verdicts, spineSequenceFacts(raw, data)));
3242
+ }
3243
+
3244
+ // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
3245
+ // Case 6h: a `size:` that disagrees with the file loads fine and collapses
3246
+ // every UV — rigid stays correct, meshes sample a corner scrap.
3247
+ //
3248
+ // 🚨 The guard is a SKIP and never a bare `return` (issue #568). A `return`
3249
+ // inside `check()` leaves the failure count and the skip count where they
3250
+ // were, which is precisely how that function decides an assertion PASSED — so
3251
+ // for as long as this line read `if (!atlas) return;` a candidate whose atlas
3252
+ // the round trip could not load was reported green by the two rules whose only
3253
+ // subject IS the atlas. It was measured on a fixture carrying a page that
3254
+ // declares twice the size of its PNG, the exact defect A06 names when the
3255
+ // parse succeeds: with a region also removed so the round trip threw, A06
3256
+ // printed `PASS`. Everything below reads `atlas.pages`, so a report of "held"
3257
+ // here is a report about zero pages.
3258
+ //
3259
+ // ⚠️ That last sentence was the whole of the next defect (issue #608). The
3260
+ // guard caught the atlas that would not PARSE and left the one that parses to
3261
+ // NO PAGES, which reaches these four loops as an empty array and walks out of
3262
+ // them with the failure and skip counts untouched — a PASS. It stayed
3263
+ // invisible only because a zero-page atlas was itself refused by A07, so no
3264
+ // green build had ever contained one; making that state legal is exactly what
3265
+ // #608 does, so the same state has to stop printing four passes about nothing.
3266
+ // `SKIP_NO_ATLAS_PAGE` is one string for all four because it is one condition.
3267
+ check('A17_ATLAS_PAGE_FILES_EXIST', () => a17AtlasPageFilesExist(verdicts, { atlas }, input));
3268
+ check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => a06AtlasPageSizeMatchesPng(verdicts, { atlas }, input, policy));
3269
+ // An overlay part must be able to draw a transparent pixel or it cannot be an
3270
+ // overlay: it would paint a solid rectangle over the untouched base, and an
3271
+ // overlay formation's whole claim is that the still frame has no seam. The base
3272
+ // plate itself is the one page allowed to be opaque: the one the rig names
3273
+ // (a cut manifest's part whose window is the crop), or, when the build names
3274
+ // none, the region that covers the whole stage.
3275
+ //
3276
+ // ⭐ Transparency is not the same thing as an alpha CHANNEL, and this assertion
3277
+ // used to conflate them (#215). Colour types 4 and 6 store alpha per pixel;
3278
+ // types 0, 2 and 3 store it in a `tRNS` chunk instead, and indexed+tRNS is the
3279
+ // ordinary output of ImageMagick, Photoshop's PNG-8 export, GIMP's indexed
3280
+ // mode, aseprite and pngquant. Judging on the colour type alone refused art
3281
+ // that was never broken — seven of one author's nine hand-drawn parts, on their
3282
+ // first build — and told them, untruthfully, that the file held no transparency
3283
+ // at all. That audit is also why the CLI's default is now `spine`, which does
3284
+ // not run this rule at all (#221): a stranger reaches this refusal only by
3285
+ // asking for `spine-html`. It still has to be both TRUE and actionable when it
3286
+ // fires, and it now has to name the profile it belongs to as well as the one
3287
+ // that does not ask — the reader opted in, and the message is where they find
3288
+ // out what they opted into.
3289
+ check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => a19OverlayPngsHaveAlpha(verdicts, { atlas }, spineStage(skeletonData, input.modelText), { regionAttachments }, input));
3290
+ // Two regions on one page whose rectangles overlap and whose footprints do
3291
+ // too — a mesh's hull, else the rectangle (issue #1099). The clause A06 held
3292
+ // about two rectangles over the same texels, read over what each region
3293
+ // draws; the argument and the footprint are `./assertions/bodies/a49.ts` and
3294
+ // `./assertions/footprints.ts`. The meshes are the loaded skeleton's, and
3295
+ // where it did not load every footprint is the rectangle A06 read.
3296
+ check('A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP', () => a49PackedFootprintsDoNotOverlap(verdicts, { atlas }, spineRegionJoins(input.atlasText, raw), skeletonData === null ? null : loadedMeshFacts()));
3297
+ // The stage box a rig asked for (issue #1168): the slot's bounding box,
3298
+ // loaded and posed at setup by spine-core, against the stage the document
3299
+ // states. The argument and both readings are `./assertions/bodies/a50.ts`.
3300
+ check('A50_STAGE_BOX_IS_THE_STAGE', () => (skeletonData === null ? skip('A50_STAGE_BOX_IS_THE_STAGE', SKIP_NO_SKELETON) : a50StageBoxIsTheStage(verdicts, spineStageBox(skeletonData, input.modelText))));
3301
+
3302
+ // -------------------------------------------------------------------------
3303
+ // Archetype assertions — the invariants the RIG declares about itself.
3304
+ //
3305
+ // These need `input.rig`, because skeleton JSON does not record which bone
3306
+ // carries the axis, which parentage is forbidden, what the canonical draw
3307
+ // order is, or which mesh is a ribbon. Every one of them reads the rig spec's
3308
+ // `invariants` block ([`src/rig.ts`](rig.ts)) and every one of them SKIPs when
3309
+ // the field it needs is absent — a rig that declares nothing is not thereby
3310
+ // certified, it is unmeasured, and the two must never print the same.
3311
+ // -------------------------------------------------------------------------
3312
+ stats.rig = input.rig ? input.rig.archetype : 'absent';
3313
+ stats.profile = profile;
3314
+
3315
+ // --- A24: motion under the axis bone stays in AXIS space -----------------
3316
+ //
3317
+ // ⭐ The keystone of an articulated cut, and it exists because of a real bug:
3318
+ // a generator wrote its travel as screen-space x/y pairs (`x: 30, y: 8` ->
3319
+ // `x: -45, y: -12`), so every key had to be re-recorded for a cut framed at a
3320
+ // different camera angle — and sibling variants of ONE cut can differ by tens
3321
+ // of degrees. Put the direction in the axis bone's setup rotation instead and
3322
+ // the keys become translateX along it, reusable across every variant. A
3323
+ // screen-space y component on any bone in that subtree therefore means
3324
+ // somebody has put the direction back into the keys.
3325
+ //
3326
+ // The axis bone itself must carry no keys at all: `invariants.axisBone` names
3327
+ // a per-cut SETUP value, and animating it swings the whole formation.
3328
+ check('A24_AXIS_SPACE_STROKE', () => a24AxisSpaceStroke(verdicts, rawBoneTimelines(raw), input));
3329
+
3330
+ // --- A25: parentage that must never happen -------------------------------
3331
+ //
3332
+ // ⚠️ Some bones are detached ON PURPOSE. An emitter that releases something
3333
+ // into the world must not ride the part that released it, or what it emits
3334
+ // gets dragged along with every stroke instead of staying where it left and
3335
+ // taking gravity. The rig states each such pair in `invariants.detached`, with
3336
+ // the reason it is tempting, because the wrong parentage still loads and still
3337
+ // animates — it just lies. That is exactly the class of invariant that belongs
3338
+ // in a machine guard rather than in prose.
3339
+ check('A25_DETACHED_BONE_PARENTAGE', () => a25DetachedBoneParentage(verdicts, rawSkeletonRoster(raw), input));
3340
+
3341
+ // --- A26: the slots array IS the rig's slot table ------------------------
3342
+ //
3343
+ // The slots array IS the draw order (z-index = array index), so a formation
3344
+ // whose illusion depends on one part occluding another depends on one
3345
+ // adjacency in that array — and nothing in the file objects to the wrong
3346
+ // order. On a still frame it can even look plausible. The rig's own slot list
3347
+ // is the canonical table, and this checks the emitted array against it in
3348
+ // both directions: nothing out of order, and nothing missing.
3349
+ //
3350
+ // ⚠️ The second half is new with issue #575 and the first half is why it had
3351
+ // to be. This clause used to accept any SUBSEQUENCE of the table, because the
3352
+ // compiler dropped a slot no skin filled and the gate was written around that
3353
+ // — so the defect #575 filed was licensed by the assertion that was supposed
3354
+ // to catch it: two production exports declaring 53 and 61 slots built green
3355
+ // at 51 and 57. `compile` now emits every declared slot, empty if nothing
3356
+ // fills it, so a shorter array is a loss and is named as one here. A slot
3357
+ // missing from the array moves every slot below it up one index, which is
3358
+ // what a `drawOrder` key's offsets are counted against.
3359
+ check('A26_SLOT_DRAW_ORDER', () => a26SlotDrawOrder(verdicts, rawSkeletonRoster(raw), input));
3360
+
3361
+ // --- A27: region name == the PNG's basename ------------------------------
3362
+ //
3363
+ // The join key is a chain of three names — attachment -> atlas region -> file —
3364
+ // and A08 only holds the first link. The second was held by convention alone:
3365
+ // an atlas could declare page `../plates/02_overlay.png` with a region
3366
+ // called anything at all, every attachment could agree with it, and the rig
3367
+ // would load with the wrong pixels under the right name. One part per page (A06
3368
+ // forces it) makes the check exact.
3369
+ check('A27_REGION_NAME_MATCHES_PAGE_FILENAME', () => a27RegionNameMatchesPageFilename(verdicts, { atlas }));
3370
+
3371
+ // --- A28: a ribbon's rows share their weights ----------------------------
3372
+ //
3373
+ // This is what makes "length without width" a property of the file rather than
3374
+ // a hope. Both vertices of a row carry the same bones at the same weights, so
3375
+ // whatever the chain does to one it does to the other and their separation can
3376
+ // only rotate — the strip curves and stretches, and never gets fatter. Give one
3377
+ // side a different weight and the strip develops a taper that grows with its
3378
+ // travel, which is the sort of thing that reads as bad art rather than as a bug.
3379
+ check('A28_RIBBON_ROWS_SHARE_WEIGHTS', () => a28RibbonRowsShareWeights(verdicts, skeletonData === null ? null : loadedMeshFacts(), input.rig));
3380
+
3381
+ // --- A29: inward travel stops where the two masses meet ------------------
3382
+ //
3383
+ // 🎯 The rule: inward travel goes at most as far as the point where the moving
3384
+ // mass touches the part that occludes it. That distance is MEASURED off the two
3385
+ // plates (`tools/measure_contact_depth.ts`) and recorded in the manifest as
3386
+ // `stroke.contact_depth`, so the ceiling is a fact about the art rather than a
3387
+ // number somebody picked. Drive past it and the frame renders two bodies
3388
+ // interpenetrating — and NOTHING in skeleton JSON objects: the animation loads,
3389
+ // plays, and is simply wrong. Exactly the shape of silent wrongness this
3390
+ // validator exists for.
3391
+ //
3392
+ // Two things spend the same clearance and so are added together:
3393
+ // * the travel itself, a translateX on a bone in the axis subtree (A24
3394
+ // guarantees there is no hidden screen-space component to miss); and
3395
+ // * the mass bone's own inward keys. `invariants.massBone` typically hangs
3396
+ // outside the axis subtree, so its keys are screen-space by design — they
3397
+ // get projected onto the axis rather than read as axis coordinates.
3398
+ // Ignoring them would let a rig pass while a recoil key closed the last few
3399
+ // pixels of the gap.
3400
+ check('A29_STROKE_WITHIN_CONTACT_DEPTH', () => a29StrokeWithinContactDepth(verdicts, rawBoneTimelines(raw), input));
3401
+
3402
+ // --- A30: inward travel stops where the drawn cover runs out -------------
3403
+ //
3404
+ // 🎯 The second ceiling, and it is NOT a restatement of A29. Contact asks when
3405
+ // two masses collide; containment asks when the moving part's leading contour
3406
+ // stops being covered by the occluder's opaque footprint. Past that point the
3407
+ // part is drawn in a place the art says is hidden — and like every failure in
3408
+ // this family it is completely silent: the animation loads, plays, and shows
3409
+ // one plate passing through another.
3410
+ //
3411
+ // A cut can have either ceiling without the other, which is why they are two
3412
+ // manifest fields and two assertions. Two plates cut from ONE piece of art are
3413
+ // adjacent at rest and never "meet", so that cut has no contact ceiling at all
3414
+ // and only a containment one.
3415
+ //
3416
+ // ⚠️ Second half, and it is what keeps the first half true: the ceiling is
3417
+ // measured on the UNDEFORMED contour, by translating the plate along the axis.
3418
+ // A scale key on any bone in the axis subtree changes the contour itself, so the
3419
+ // measured number stops describing the rig — quietly, because the file still
3420
+ // validates. Rather than assert a number that no longer means anything, refuse
3421
+ // the deformation. A cut that wants squash under a declared ceiling has to
3422
+ // re-measure containment for the scaled contour and say so.
3423
+ check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
3424
+
3425
+ // --- A18: determinism ----------------------------------------------------
3426
+ // The body is `./assertions/bodies/a18.ts` (issue #1060): the model document's clause, which the model side
3427
+ // runs too, and the Spine pair's two clauses handed to it as this side's encoding findings, in their order.
3428
+ // Same vacuous-pass trap as A09: re-gating artifacts already on disk hands this assertion no second compile.
3429
+ check('A18_DETERMINISTIC_EMIT', () => {
3430
+ const again = input.reEmit;
3431
+ const encoding =
3432
+ again === undefined
3433
+ ? []
3434
+ : [
3435
+ ...(again.skeletonText !== input.skeletonText ? ['recompiling produced a different skeleton.json'] : []),
3436
+ ...(again.atlasText !== input.atlasText ? ['recompiling produced a different skeleton.atlas'] : []),
3437
+ ];
3438
+ a18DeterministicEmit(verdicts, { again: again !== undefined, encoding, first: input.modelText, second: again?.modelText ?? '' });
3439
+ });
3440
+
3441
+ // --- every assertion leaves a row ----------------------------------------
3442
+ //
3443
+ // 🔒 **A missing row is the vacuous pass one level up** (issue #568). Twenty
3444
+ // assertions live inside `if (skeletonData) {` above, so a round trip that
3445
+ // throws does not skip them — it never reaches them, and they appear in none
3446
+ // of the four lists. Measured on a fixture whose atlas was missing a region
3447
+ // the skeleton names: 13 of 42 printed a verdict, 9 more printed `PROF`, and
3448
+ // 20 printed nothing at all. The run exits 1 and the reader is right to fix
3449
+ // A00 first, which is exactly why the silence survives — nobody counts the
3450
+ // rows on a red run. A report that names 22 of 42 and says nothing about the
3451
+ // rest is telling a reader that those rules are fine, in the only way a
3452
+ // report can: by not mentioning them.
3453
+ //
3454
+ // ⚠️ The sweep DERIVES its list rather than keeping one, because a hand-kept
3455
+ // roster of "assertions behind the round trip" is a second place to update
3456
+ // and would be wrong the first time somebody moved a `check` call. Anything
3457
+ // `ASSERTION_NAMES` knows that reached here with no row did not run, and what
3458
+ // it is honest to say about it is decided by the state:
3459
+ //
3460
+ // * the profile does not carry that kind of rule — `PROF`, the same verdict
3461
+ // `check()`'s own guard would have recorded had the call been reached;
3462
+ // * the round trip produced nothing — `SKIP`, naming that;
3463
+ // * anything else — a FAIL, **by name**. An assertion that vanished while
3464
+ // the skeleton was loaded is a defect in this file, and inventing a SKIP
3465
+ // for it would be the same silence one ring further out. The one state
3466
+ // the sweep is allowed to explain is the one it can prove.
3467
+ const reported = new Set<string>([
3468
+ ...passed,
3469
+ ...failures.map((f) => f.assertion),
3470
+ ...skipped.map((s) => s.assertion),
3471
+ ...profileSkipped.map((p) => p.assertion),
3472
+ ]);
3473
+ for (const name of ASSERTION_NAMES) {
3474
+ if (reported.has(name)) continue;
3475
+ const kind = ASSERTION_KIND[name];
3476
+ if (kind !== 'validity' && !kindRunsUnder(kind, profile)) profileSkipped.push({ assertion: name, kind });
3477
+ else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
3478
+ else {
3479
+ fail(
3480
+ name,
3481
+ 'the assertion left no row: its body was never reached, and the round trip that would explain that ' +
3482
+ 'succeeded. A guard above returned before the call — find it and make it a SKIP naming what was absent',
3483
+ );
3484
+ }
3485
+ }
3486
+
3487
+ return { failures, passed, skipped, profileSkipped, profile, stats };
3488
+ }
3489
+
3490
+ /**
3491
+ * Decode a weighted mesh's `vertices` run into per-vertex (boneIndex, weight).
3492
+ *
3493
+ * The encoding carries no marker at all — weighted versus unweighted is decided
3494
+ * by a length comparison — so every assertion that talks
3495
+ * about weights has to walk the run itself.
3496
+ */
3497
+ function meshWeightsOf(mesh: MeshAttachment): Array<Array<{ bone: number; weight: number }>> {
3498
+ const out: Array<Array<{ bone: number; weight: number }>> = [];
3499
+ if (!mesh.bones) return out;
3500
+ let bi = 0;
3501
+ let vi = 0;
3502
+ while (bi < mesh.bones.length) {
3503
+ const boneCount = mesh.bones[bi++];
3504
+ const vertex: Array<{ bone: number; weight: number }> = [];
3505
+ for (let n = 0; n < boneCount; n++, bi++, vi += 3) {
3506
+ vertex.push({ bone: mesh.bones[bi], weight: mesh.vertices[vi + 2] });
3507
+ }
3508
+ out.push(vertex);
3509
+ }
3510
+ return out;
3511
+ }
3512
+
3513
+ export function atlasDirOf(atlasPath: string): string {
3514
+ return dirname(resolve(atlasPath));
3515
+ }
3516
+
3517
+ // ---------------------------------------------------------------------------
3518
+ // skeletonValues — the round trip read as VALUES rather than as assertions
3519
+ // ---------------------------------------------------------------------------
3520
+
3521
+ /**
3522
+ * One value the parser read out of a skeleton file, at a path that names it.
3523
+ *
3524
+ * `bones/hip/setup/rotation`, `skins/default/head/head/regionUVs/12`,
3525
+ * `animations/walk/RotateTimeline/bone:hip/key/3/v1`. Numbers stay numbers so
3526
+ * that a comparison can state a tolerance; everything else — a blend mode the
3527
+ * runtime holds as an enum, an attachment name, a boolean, an absent object —
3528
+ * is a string, and is compared exactly.
3529
+ */
3530
+ export interface SkeletonValue {
3531
+ /** Stable, name-keyed, and never an index into an emitted array (issue #45). */
3532
+ path: string;
3533
+ value: number | string;
3534
+ }
3535
+
3536
+ /**
3537
+ * Keys the walk below does not read, each with the reason it is not a value the
3538
+ * file carries. It is a SKIP list rather than an include list on purpose: the
3539
+ * field names come from the parser, so a field spine-core starts reading is
3540
+ * compared the day it starts reading it, and the only hand-kept part is the
3541
+ * short list of things that are demonstrably not in the file.
3542
+ *
3543
+ * ⚠️ `id` is the sharp one. `VertexAttachment.id` is a process-wide counter, so
3544
+ * two parses in one process disagree about it by construction — and it reaches
3545
+ * `DeformTimeline.getPropertyIds()`, which is why the timeline key below is
3546
+ * built from the resolved owner rather than from the property ids.
3547
+ */
3548
+ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
3549
+ a: 'the derived world matrix',
3550
+ b: 'the derived world matrix',
3551
+ c: 'the derived world matrix',
3552
+ d: 'the derived world matrix',
3553
+ world: 'derived by the runtime',
3554
+ local: 'derived by the runtime',
3555
+ worldX: 'derived by the runtime',
3556
+ worldY: 'derived by the runtime',
3557
+ region: 'the atlas, which is not the skeleton',
3558
+ regions: 'the atlas, which is not the skeleton',
3559
+ uvs: 'computed from the region',
3560
+ offsets: 'computed from the region',
3561
+ tempColor: 'runtime scratch',
3562
+ deform: 'runtime scratch on the setup pose',
3563
+ id: 'a process-wide counter, not a value in the file',
3564
+ index: 'the array position this walk already iterates by name',
3565
+ timelineIds: 'derived from the timelines',
3566
+ timelineSlots: 'derived from the skin',
3567
+ propertyIds: 'carries an attachment id, which is a counter (see above)',
3568
+ };
3569
+
3570
+ /**
3571
+ * The keys above that ARE a value the file carries on these owners, by the
3572
+ * owner's class name (issue #1084).
3573
+ *
3574
+ * The skip list is keyed by field name alone, and four of its names are also
3575
+ * the name of a field the parser reads off the file on a different class. Each
3576
+ * owner below was found by walking the parsed form of the twelve editor exports
3577
+ * and the seven gallery builds for every class carrying a key the list names,
3578
+ * and each field was planted and posed before it was let back in:
3579
+ *
3580
+ * - `a`, `b` — a `Color`'s alpha and blue channels, which share their names with
3581
+ * two entries of a bone's world matrix. Every slot colour, dark colour, bone
3582
+ * colour and attachment colour lost half its channels to that: a slot's alpha
3583
+ * `ff` → `80` moved no value at all, and posed, it changes every slot row.
3584
+ * - `local` — a slider's `local` flag, which shares its name with a bone pose's
3585
+ * derived `local`. Flipped on `gallery/look`'s `yaw`, it moves the posed bones.
3586
+ * - `offsets` — a transform constraint's six offsets (`rotation`, `x`, `y`,
3587
+ * `scaleX`, `scaleY`, `shearY` in the file), which share their name with a
3588
+ * sequence's region offsets. A `y` offset moved on `6-arcs-pro`'s `tail`
3589
+ * moved no value and moves the posed bones.
3590
+ *
3591
+ * A fifth entry, `properties` (*derived from the constraint*), is gone rather
3592
+ * than excepted: the one class measured carrying it is the transform constraint,
3593
+ * where it is the file's from/to map, so the reason was not true of anything.
3594
+ *
3595
+ * ⚠️ An exception rather than a rewrite of the list: the list's design — a
3596
+ * field the parser starts reading is compared the day it starts — is right,
3597
+ * and what was wrong was that a name stood for every class carrying it.
3598
+ */
3599
+ const VALUE_ON_THESE_OWNERS: Record<string, readonly string[]> = {
3600
+ a: ['Color'],
3601
+ b: ['Color'],
3602
+ local: ['SliderData'],
3603
+ offsets: ['TransformConstraintData'],
3604
+ };
3605
+
3606
+ /** Whether the walk leaves `key` out on an object of class `owner`. */
3607
+ function notAValue(owner: string, key: string): boolean {
3608
+ return key in NOT_A_VALUE_IN_THE_FILE && !(VALUE_ON_THESE_OWNERS[key] ?? []).includes(owner);
3609
+ }
3610
+
3611
+ /**
3612
+ * The class an object was parsed into when that class is one of several sharing
3613
+ * a shape — it extends another class — or `null`.
3614
+ *
3615
+ * Reflection reads fields, and two classes with the same fields are the same
3616
+ * object to it: a slider reading `rotate` holds a `FromRotate` and one reading
3617
+ * `x` a `FromX`, both `{ offset, to }`, so moving a slider to another property
3618
+ * moved no value (issue #1084). Measured over the parsed corpus, the only
3619
+ * unnamed objects the walk expands whose class extends another are the
3620
+ * transform and slider property classes (`From*`, `To*`); a pose, a colour and a
3621
+ * sequence are classes of their own, so this adds nothing under them.
3622
+ */
3623
+ function sharedShapeKind(obj: object): string | null {
3624
+ const proto = Object.getPrototypeOf(obj) as object | null;
3625
+ if (proto === null || proto === Object.prototype) return null;
3626
+ const parent = Object.getPrototypeOf(proto) as object | null;
3627
+ if (parent === null || parent === Object.prototype) return null;
3628
+ return (obj.constructor as { name?: string } | undefined)?.name ?? null;
3629
+ }
3630
+
3631
+ /** How deep a chain of unnamed objects may go before the walk says so and stops. */
3632
+ const VALUE_WALK_DEPTH = 10;
3633
+
3634
+ /**
3635
+ * Reflection over the parsed form: numbers and strings as they are, arrays by
3636
+ * index, objects by their own keys in **sorted** order, and any nested object
3637
+ * that carries a `name` by that name rather than by expansion — which is what
3638
+ * makes a cross-reference (`parent`, `boneData`, `endSlot`, a constraint's
3639
+ * `bones`) a name and terminates every cycle the runtime's back-references
3640
+ * would otherwise walk forever.
3641
+ *
3642
+ * Sorted rather than insertion-ordered because `A18_DETERMINISTIC_EMIT`'s rule
3643
+ * applies here too: nothing whose order is the runtime's business may decide
3644
+ * what this function emits.
3645
+ */
3646
+ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: number): void {
3647
+ if (value === null || value === undefined) {
3648
+ out.push({ path, value: '(none)' });
3649
+ return;
3650
+ }
3651
+ if (typeof value === 'number' || typeof value === 'string') {
3652
+ out.push({ path, value });
3653
+ return;
3654
+ }
3655
+ if (typeof value === 'boolean') {
3656
+ out.push({ path, value: value ? 'true' : 'false' });
3657
+ return;
3658
+ }
3659
+ if (typeof value === 'function') return;
3660
+ if (ArrayBuffer.isView(value) || Array.isArray(value)) {
3661
+ const list = value as ArrayLike<unknown>;
3662
+ // A list whose every entry is an unnamed object of its own shared-shape
3663
+ // class — a transform's `properties` and each one's `to` — is keyed by those
3664
+ // classes rather than by position: the file writes them as an object keyed
3665
+ // by property name, any writer may key it in its own order, and the
3666
+ // parser's list follows that order. By position, the same map written
3667
+ // backwards would read as every property moved (issue #1084).
3668
+ const kinds: string[] = [];
3669
+ for (let i = 0; i < list.length; i++) {
3670
+ const entry = list[i];
3671
+ const kind =
3672
+ typeof entry === 'object' && entry !== null && typeof (entry as Record<string, unknown>).name !== 'string'
3673
+ ? sharedShapeKind(entry)
3674
+ : null;
3675
+ if (kind === null || kinds.includes(kind)) break;
3676
+ kinds.push(kind);
3677
+ }
3678
+ const byKind = list.length > 0 && kinds.length === list.length;
3679
+ for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${byKind ? kinds[i] : i}`, out, depth + 1);
3680
+ return;
3681
+ }
3682
+ if (typeof value !== 'object') return;
3683
+ const obj = value as Record<string, unknown>;
3684
+ if (depth > 0 && typeof obj.name === 'string') {
3685
+ out.push({ path, value: obj.name });
3686
+ return;
3687
+ }
3688
+ if (depth > VALUE_WALK_DEPTH) {
3689
+ out.push({ path, value: '(deeper than the walk goes)' });
3690
+ return;
3691
+ }
3692
+ const owner = (obj.constructor as { name?: string } | undefined)?.name ?? '';
3693
+ // The class, where it is the one thing the fields cannot say — see `sharedShapeKind`.
3694
+ const kind = depth > 0 ? sharedShapeKind(obj) : null;
3695
+ if (kind !== null) out.push({ path: `${path}/kind`, value: kind });
3696
+ for (const key of Object.keys(obj).sort()) {
3697
+ if (notAValue(owner, key)) continue;
3698
+ pushValue(obj[key], `${path}/${key}`, out, depth + 1);
3699
+ }
3700
+ }
3701
+
3702
+ /**
3703
+ * Which timeline this is, in words that both sides of a comparison can reach.
3704
+ *
3705
+ * The class name and the OWNER'S NAME — never the property ids, which carry
3706
+ * array indices and, for a deform timeline, a counter. A rig that reorders its
3707
+ * bones is a structural finding `diff` already makes; it must not also arrive
3708
+ * here as a timeline nobody can pair.
3709
+ */
3710
+ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkeletonData']>): string {
3711
+ const kind = timeline.constructor?.name ?? 'Timeline';
3712
+ const asBone = timeline as Timeline & Partial<{ boneIndex: number }>;
3713
+ if (isBoneTimeline(asBone)) return `${kind}/bone:${data.bones[asBone.boneIndex]?.name ?? `#${asBone.boneIndex}`}`;
3714
+ const asSlot = timeline as Timeline & Partial<{ slotIndex: number }>;
3715
+ if (isSlotTimeline(asSlot)) {
3716
+ const slot = data.slots[asSlot.slotIndex]?.name ?? `#${asSlot.slotIndex}`;
3717
+ const attachment = (timeline as Timeline & Partial<{ attachment: { name: string } }>).attachment;
3718
+ return `${kind}/slot:${slot}${attachment === undefined ? '' : `:${attachment.name}`}`;
3719
+ }
3720
+ const asConstraint = timeline as Timeline & Partial<{ constraintIndex: number }>;
3721
+ if (isConstraintTimeline(asConstraint)) {
3722
+ return `${kind}/constraint:${data.constraints[asConstraint.constraintIndex]?.name ?? `#${asConstraint.constraintIndex}`}`;
3723
+ }
3724
+ return `${kind}/skeleton`;
3725
+ }
3726
+
3727
+ /**
3728
+ * Every value a skeleton file carries, as the **parser** understands it.
3729
+ *
3730
+ * ## Why this is here and not in `diff.ts`
3731
+ *
3732
+ * `diff` compares structure over raw JSON and says so in its own header:
3733
+ * *"Pure JSON reading — no spine-core, no filesystem."* Comparing the values
3734
+ * inside that structure needs the format's per-field defaults — `time` absent
3735
+ * is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 but its `mix`
3736
+ * is 1 — and a second spelling of those inside the gate is exactly what this
3737
+ * repository refuses (CLAUDE.md, *The compiler never invents a value*). So the
3738
+ * defaults are taken from the one reader that owns them, which means the
3739
+ * runtime, which means this file: `CLAUDE.md`'s *Conventions* names the three
3740
+ * modules allowed to link spine-core and `src/validate.ts` is the one that
3741
+ * "owns the round trip". `src/bonedist.ts` is the precedent for the other half
3742
+ * — an instrument that needs the runtime and reaches it through `render.ts`
3743
+ * rather than linking it itself. `diff.ts` compares what this returns.
3744
+ *
3745
+ * ## What it covers
3746
+ *
3747
+ * Everything on `SkeletonData` that is not on the skip list above: the header
3748
+ * and stage, every bone's setup pose and `length`, every slot's colours, blend
3749
+ * and setup attachment, every attachment in every skin (a region's offsets,
3750
+ * rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
3751
+ * triangles, hull and edges; a bounding box's, path's and clipping shape's
3752
+ * vertices), every constraint's pose, flags and modes — a transform's offsets
3753
+ * and its property map, each entry by its class, and a slider's property by its
3754
+ * class (issue #1084) — every event's payload, and for
3755
+ * every timeline every frame — time and values, from the runtime's own
3756
+ * `getFrameEntries()` — its curve type and Bezier samples, and its deform
3757
+ * vertices, attachment names, draw orders and event payloads.
3758
+ *
3759
+ * ## What it does not
3760
+ *
3761
+ * - **`version` and `hash`.** The rig spec has no field for either; `ingest`
3762
+ * reports them as `HEADER_REDERIVED` and `HEADER_BOOKKEEPING` findings, and
3763
+ * a rebuild restating the runtime rigc links is the whole reason the corpus
3764
+ * gate is `diff` at 1.000 rather than a byte comparison (`docs/INGEST.md`
3765
+ * §2.3). They are named here so that skipping them is a decision a reader
3766
+ * can see rather than an omission.
3767
+ * - **Anything below one float32 step.** `spine-core` stores frames, curves and
3768
+ * vertices in `Float32Array`, so two values that round to the same float32
3769
+ * are equal to this walk whatever the file says. The comparison's tolerance
3770
+ * states that bound rather than hiding it.
3771
+ * - **A Bezier's control points as such.** The parser samples them into
3772
+ * `curves` (`setBezier`), so what is compared is the sampled curve; a moved
3773
+ * handle moves the samples, but by a different amount than it moved the
3774
+ * handle.
3775
+ */
3776
+ export function skeletonValues(skeletonText: string, atlasText: string): SkeletonValue[] {
3777
+ const data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(
3778
+ JSON.parse(skeletonText),
3779
+ );
3780
+ const out: SkeletonValue[] = [];
3781
+ const header = data as unknown as Record<string, unknown>;
3782
+ for (const field of ['x', 'y', 'width', 'height', 'referenceScale', 'fps', 'imagesPath', 'audioPath', 'name']) {
3783
+ pushValue(header[field], `skeleton/${field}`, out, 1);
3784
+ }
3785
+ for (const bone of data.bones) {
3786
+ const at = `bones/${bone.name}`;
3787
+ const rec = bone as unknown as Record<string, unknown>;
3788
+ for (const key of Object.keys(rec).sort()) {
3789
+ if (notAValue('BoneData', key) || key === 'name') continue;
3790
+ pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3791
+ }
3792
+ }
3793
+ for (const slot of data.slots) {
3794
+ const at = `slots/${slot.name}`;
3795
+ const rec = slot as unknown as Record<string, unknown>;
3796
+ for (const key of Object.keys(rec).sort()) {
3797
+ if (notAValue('SlotData', key) || key === 'name') continue;
3798
+ pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3799
+ }
3800
+ }
3801
+ for (const skin of data.skins) {
3802
+ const at = `skins/${skin.name}`;
3803
+ pushValue(skin.color, `${at}/color`, out, 1);
3804
+ pushValue(skin.bones, `${at}/bones`, out, 1);
3805
+ pushValue(skin.constraints, `${at}/constraints`, out, 1);
3806
+ for (const [slotIndex, held] of skin.attachments.entries()) {
3807
+ if (held === undefined || held === null) continue;
3808
+ const slot = data.slots[slotIndex]?.name ?? `#${slotIndex}`;
3809
+ const byName = held as unknown as Record<string, unknown>;
3810
+ for (const placeholder of Object.keys(byName).sort()) {
3811
+ const attachment = byName[placeholder] as Record<string, unknown>;
3812
+ const where = `${at}/${slot}/${placeholder}`;
3813
+ // The attachment's TYPE, which is the one thing reflection cannot see:
3814
+ // a region and a mesh differ in their fields, and a walk that only read
3815
+ // the fields would call a swap a pile of unpaired paths rather than a
3816
+ // kind that changed.
3817
+ pushValue(attachment.constructor?.name ?? '(unknown)', `${where}/kind`, out, 1);
3818
+ pushValue(attachment, where, out, 0);
3819
+ }
3820
+ }
3821
+ }
3822
+ for (const constraint of data.constraints) {
3823
+ // The KIND is part of the path, not just a value under it (issue #692):
3824
+ // `leg` may be an ik constraint and a transform constraint at once, and a
3825
+ // path keyed on the name alone pairs the first of one file with the second
3826
+ // of the other. Measured on the export that made the card: a rebuild
3827
+ // carrying both, correctly, reported `values.constraints` 197/199 with
3828
+ // `constraints/<name>/kind "IkConstraintData" vs "TransformConstraintData"`
3829
+ // as the difference — the comparison contradicting itself rather than the
3830
+ // file. The class name is the same string `/kind` already carries, so the
3831
+ // two sides pair wherever they agree about what the constraint is.
3832
+ const at = `constraints/${constraint.constructor?.name ?? '(unknown)'}:${constraint.name}`;
3833
+ pushValue(constraint.constructor?.name ?? '(unknown)', `${at}/kind`, out, 1);
3834
+ pushValue(constraint, at, out, 0);
3835
+ }
3836
+ for (const event of data.events) pushValue(event, `events/${event.name}`, out, 0);
3837
+ for (const animation of data.animations) {
3838
+ const at = `animations/${animation.name}`;
3839
+ pushValue(animation.duration, `${at}/duration`, out, 1);
3840
+ // Timelines are grouped by their key and numbered within the group, so that
3841
+ // two timelines a runtime distinguishes by object identity — one deform per
3842
+ // skin over the same slot and attachment — still pair up in file order
3843
+ // rather than colliding on one path.
3844
+ const grouped = new Map<string, Timeline[]>();
3845
+ for (const timeline of animation.timelines) {
3846
+ const key = timelineKey(timeline, data);
3847
+ const held = grouped.get(key);
3848
+ if (held === undefined) grouped.set(key, [timeline]);
3849
+ else held.push(timeline);
3850
+ }
3851
+ for (const key of [...grouped.keys()].sort()) {
3852
+ const group = grouped.get(key) ?? [];
3853
+ for (const [n, timeline] of group.entries()) {
3854
+ const where = `${at}/${key}${group.length > 1 ? `#${n}` : ''}`;
3855
+ // The frames, split into time and values by the runtime's own count of
3856
+ // entries per frame rather than by a table of what each timeline kind
3857
+ // keys. Entry 0 is the time for every timeline spine-core defines.
3858
+ const entries = timeline.getFrameEntries();
3859
+ for (let i = 0; i < timeline.frames.length; i++) {
3860
+ const slot = i % entries;
3861
+ pushValue(timeline.frames[i], `${where}/key/${Math.floor(i / entries)}/${slot === 0 ? 'time' : `v${slot}`}`, out, 1);
3862
+ }
3863
+ const rec = timeline as unknown as Record<string, unknown>;
3864
+ for (const field of Object.keys(rec).sort()) {
3865
+ if (notAValue(timeline.constructor?.name ?? '', field) || field === 'frames') continue;
3866
+ // The owner is in the path already; comparing the index as well would
3867
+ // report one reordering twice, in a measure that is not about order.
3868
+ if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
3869
+ pushValue(rec[field], `${where}/${field}`, out, 1);
3870
+ }
3871
+ }
3872
+ }
3873
+ }
3874
+ return out;
3875
+ }