spine-rigc 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
package/src/validate.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  // rectangle on a shared page.
66
66
  import { readPlate } from '../tools/plate.ts';
67
67
  import { pageFootprint, pageGridSentence } from './atlas.ts';
68
+ import { frameRegionName } from './core/uvs.ts';
68
69
  import {
69
70
  BEZIER_POINTS,
70
71
  curveStorage,
@@ -99,11 +100,34 @@ import {
99
100
  walkTimelines,
100
101
  } from './timelines.ts';
101
102
  import type { RigInfo } from './types.ts';
102
-
103
- export interface Failure {
104
- assertion: string;
105
- detail: string;
106
- }
103
+ import { ASSERTION_KIND, kindRunsUnder, type AssertionProfile } from './assertions/kinds.ts';
104
+ import { verdictHarness, type Failure } from './assertions/harness.ts';
105
+ import { atStoredKey, isObj, type Json } from './assertions/values.ts';
106
+ import type { SkinEntryFacts } from './assertions/facts/skin_entries.ts';
107
+ import type { AtlasPageFacts } from './assertions/facts/atlas_pages.ts';
108
+ import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_colour.ts';
109
+ import { a03RegionWidthHeightFinite } from './assertions/bodies/a03.ts';
110
+ import { a11NoClippingAttachments } from './assertions/bodies/a11.ts';
111
+ import { a17AtlasPageFilesExist } from './assertions/bodies/a17.ts';
112
+ import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from './assertions/bodies/a45.ts';
113
+ import {
114
+ SKIP_NO_ANIMATION,
115
+ SKIP_NO_ATLAS,
116
+ SKIP_NO_ATLAS_PAGE,
117
+ SKIP_NO_ATLAS_REGION,
118
+ SKIP_NO_ATTACHMENT_REGION_JOIN,
119
+ SKIP_NO_DECLARED_DURATION,
120
+ SKIP_NO_LINKED_MESH,
121
+ SKIP_NO_MESH_ATTACHMENT,
122
+ SKIP_NO_PHYSICS_CONSTRAINT,
123
+ SKIP_NO_POSE,
124
+ SKIP_NO_SEQUENCE,
125
+ SKIP_NO_SKELETON,
126
+ SKIP_NO_TIMELINE,
127
+ SKIP_NO_TWO_COLOR_TINT,
128
+ } from './assertions/reasons.ts';
129
+
130
+ export type { Failure } from './assertions/harness.ts';
107
131
 
108
132
  /**
109
133
  * Which body of rules to hold the artifact to.
@@ -129,7 +153,7 @@ export interface Failure {
129
153
  * no longer honestly serve both — so the choice is made at every call site now,
130
154
  * by the caller who knows which question they are asking.
131
155
  */
132
- export type ValidateProfile = 'spine' | 'spine-html';
156
+ export type ValidateProfile = AssertionProfile;
133
157
 
134
158
  export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
135
159
 
@@ -146,84 +170,8 @@ export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-ht
146
170
  */
147
171
  export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
148
172
 
149
- /**
150
- * What kind of rule each assertion is. Every assertion has an entry, and
151
- * `check()` throws on a name that has none — a new assertion must state its kind
152
- * rather than defaulting into one, because the default would decide, silently,
153
- * whether it runs on foreign data.
154
- *
155
- * validity — the file is wrong for any consumer. Runs under every profile.
156
- * renderer — valid Spine that this project's renderer or frame budget refuses.
157
- * archetype — a structural rule about rigc's own formations, meaningless to a
158
- * skeleton rigc did not compile.
159
- *
160
- * Two assertions are MIXED and are marked `validity` here because their
161
- * validity half must never stop running; their policy clauses are gated inside
162
- * the assertion body against `profile`, and each such clause says so where it
163
- * lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
164
- * it names are validity; pma / rotation / two regions over the same texels are
165
- * policy — the rectangle moved across that line in issue #694, and the reason
166
- * is stated where it now sits) and A20 (weight coherence is validity; requiring
167
- * a mesh to be weighted at all is policy).
168
- *
169
- * A08 was the third until issue #574 retired its policy clause. It required a
170
- * skin entry's placeholder to be spelled like the region it resolves to, under
171
- * a renderer that resolves art by `path` and has never read a placeholder — so
172
- * restating it as the renderer's own join made it a tautology over the validity
173
- * half. The argument, with the renderer lines it is measured against, sits above
174
- * `check('A08_…')`.
175
- */
176
- const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
177
- A00_ROUNDTRIP_PARSE: 'validity',
178
- A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
179
- A02_NO_BONE_TRANSFORM_KEY: 'validity',
180
- A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
181
- A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
182
- A05_CURVE_ARRAY_LENGTH: 'validity',
183
- A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
184
- A07_ATLAS_TEXT_SHAPE: 'validity',
185
- A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
186
- A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
187
- A10_NO_NAN_AFTER_STEPPING: 'validity',
188
- A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
189
- A12_NO_DARK_COLOR: 'renderer',
190
- A13_MESH_BUDGET: 'renderer',
191
- A14_NO_FULL_FRAME_MESH: 'renderer',
192
- A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
193
- A16_SKELETON_VERSION_4_3: 'validity',
194
- A17_ATLAS_PAGE_FILES_EXIST: 'validity',
195
- A18_DETERMINISTIC_EMIT: 'validity',
196
- A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
197
- A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
198
- A21_MESH_RIM_PINNED: 'archetype',
199
- A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
200
- A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
201
- A24_AXIS_SPACE_STROKE: 'archetype',
202
- A25_DETACHED_BONE_PARENTAGE: 'archetype',
203
- A26_SLOT_DRAW_ORDER: 'archetype',
204
- A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
205
- A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
206
- A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
207
- A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
208
- A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
209
- A32_EVENT_KEYS_RESOLVE: 'validity',
210
- A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
211
- A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
212
- A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
213
- A36_PATH_CONSTRAINT_EFFECTIVE: 'validity',
214
- A37_SLIDER_CONSTRAINT_EFFECTIVE: 'validity',
215
- A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
216
- A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
217
- A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
218
- A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
219
- A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
220
- A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
221
- A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
222
- A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
223
- A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
224
- A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
225
- A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
226
- };
173
+ // What kind of rule each assertion is — `./assertions/kinds.ts` since issue
174
+ // #1025, where the model side's harness reads the same table.
227
175
 
228
176
  /**
229
177
  * Every assertion this validator knows, in registry order.
@@ -241,112 +189,27 @@ export function assertionCountForProfile(profile: ValidateProfile): number {
241
189
  return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
242
190
  }
243
191
 
244
- /**
245
- * What a SKIP says when `A00_ROUNDTRIP_PARSE` handed an assertion nothing to
246
- * look at.
247
- *
248
- * Two of them rather than one, because what an assertion was denied is the whole
249
- * content of its SKIP: `A20` wanted the loaded skeleton and `A06` wanted the
250
- * loaded atlas, and a reader who is told which can tell a rule that is waiting
251
- * on the parse from one that is waiting on the art. Both point at A00 instead of
252
- * restating its detail, which the FAIL row prints in full.
253
- *
254
- * Exported so a control compares against them rather than quoting them — the
255
- * same reason `ASSERTION_NAMES` is exported.
256
- */
257
- export const SKIP_NO_SKELETON = 'the round trip did not produce a skeleton to measure (A00 owns that failure)';
258
- export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure (A00 owns that failure)';
259
- /**
260
- * The third of them, and it is NOT waiting on A00 (issue #608): the atlas parsed
261
- * and it has no pages, which is what a compile that measured no art writes. The
262
- * four rules whose only subject is a page — A06, A17, A19, A27 — have nothing to
263
- * look at, and an empty loop walking out of `check()` is reported as a PASS.
264
- */
265
- /*
266
- * **An empty subject list: SKIP or PASS, and which one is derived (issue #580).**
267
- *
268
- * `check()` records a pass when a body runs to its end without a `fail()` or a
269
- * `skip()`, so a body whose main construct is a loop over a list that is EMPTY
270
- * passes having measured nothing — the vacuous green one ring out from the bare
271
- * `return` guards issue #568 converted. Two different things can be true of such
272
- * a loop, and the criterion decides between them by reading the rule's own name
273
- * and its `fail()` sentences rather than by preference:
274
- *
275
- * * A name of the form ⟨subject⟩_⟨property⟩ — `REGION_WIDTH_HEIGHT_FINITE`,
276
- * `MESH_TRIANGLES_AND_ENCODING`, `PHYSICS_CONSTRAINT_EFFECTIVE`,
277
- * `ATLAS_PAGE_SIZE_MATCHES_PNG` — quantifies over the subject it names, and
278
- * its `fail()` names a member of that subject, the value found and the value
279
- * required. With no member, nothing was measured: it **SKIPs**, and the
280
- * reason names the subject that was absent.
281
- * * A name of the form NO_⟨construct⟩ — `NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS`,
282
- * `NO_BONE_TRANSFORM_KEY`, `NO_CLIPPING_ATTACHMENTS`, `NO_DARK_COLOR`,
283
- * `NO_FULL_FRAME_MESH` — quantifies over occurrences of something a correct
284
- * artifact has NONE of, and its `fail()` reports that the construct is
285
- * present rather than measuring a value on it. Zero occurrences IS the
286
- * measurement, so it **PASSes**: the loop is a search of the artifact, and
287
- * the artifact is what it measured.
288
- * * A name carrying both halves takes each at its word.
289
- * `A15_IDLE_NO_MESH_BONE_KEYS` is the pattern and already reads this way: no
290
- * `idle` animation, or an `idle` with no bone timeline, is the named subject
291
- * absent and SKIPs; no mesh-driving bone among the bones `idle` does key is
292
- * the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` was the same
293
- * shape until issue #902, and is the next bullet's now: #882 gave it a
294
- * setup-pose clause whose subject is the bones, beside the stepping clause
295
- * whose subject is the animations.
296
- * * An assertion with more than one clause SKIPs only when EVERY clause had
297
- * nothing to measure, which is the shape `A09`, `A33` and `A38` already
298
- * carry (`polygons.length === 0 && endsChecked === 0`), and `A10` since #902.
299
- * `A13_MESH_BUDGET` is why the clause is stated: a declared slot budget is
300
- * measured against a count of mesh slots, and zero is a count, so that half
301
- * passes on a rig with no mesh — while a rig that declares only a TRIANGLE
302
- * budget has nothing left to measure and skips.
303
- *
304
- * ⚠️ What the criterion is not allowed to become is a table of assertions with
305
- * their verdicts written beside them. Every row above is decided by reading the
306
- * name and the sentences the assertion already prints, so a rule added tomorrow
307
- * is decided by the same reading and nobody re-opens this question.
308
- */
309
-
310
- /**
311
- * What a SKIP says when the artifact carries no member of the subject a rule
312
- * quantifies over (issue #580), by subject.
313
- *
314
- * One constant per SUBJECT rather than one per assertion, for the reason the two
315
- * above are two: what the rule was denied is the whole content of its SKIP, and
316
- * three rules denied the same thing should say so in the same words. Exported so
317
- * a control compares against them rather than quoting them.
318
- */
319
- export const SKIP_NO_REGION_ATTACHMENT = 'the skeleton carries no region attachment';
320
- export const SKIP_NO_MESH_ATTACHMENT = 'the skeleton carries no mesh attachment';
321
- export const SKIP_NO_ANIMATION = 'the skeleton carries no animation';
322
- /**
323
- * A10's (issue #902): its setup clause reads the bones and its stepping clause
324
- * the animations, so it skips only when the skeleton carries neither — and the
325
- * sentence is `SKIP_NO_ANIMATION`'s with the second subject added, so the two
326
- * cannot drift into different words for the same absence.
327
- */
328
- export const SKIP_NO_POSE = `${SKIP_NO_ANIMATION} and no bone, so there is no setup pose to read and nothing to step`;
329
- export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
330
- export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
331
- export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
332
- export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
333
- export const SKIP_NO_ATTACHMENT_REGION_JOIN =
334
- 'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
335
- export const SKIP_NO_TWO_COLOR_TINT =
336
- 'no slot declares a "dark" colour and no animation keys an "rgba2" or "rgb2" timeline, so there is no two-colour tint to read back';
337
- export const SKIP_NO_SEPARABLE_COLOR =
338
- 'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
339
- export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
340
- export const SKIP_NO_SEQUENCE =
341
- 'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
342
- /**
343
- * A09's, which predates this list and joins it rather than being rewritten: it
344
- * is the same fact about the same subject, and a control that compares against
345
- * eight constants and quotes the ninth is a control with a hand-kept exception
346
- * in it.
347
- */
348
- export const SKIP_NO_DECLARED_DURATION =
349
- 'the motion spec declares no animations and the skeleton has none — a static rig has no duration to compare';
192
+ // What a SKIP says, by what the assertion was denied — `./assertions/reasons.ts`
193
+ // since issue #1025, so the assertion bodies that moved out of this file print
194
+ // the same sentences on both sides; every one is re-exported from here.
195
+ export {
196
+ SKIP_NO_ANIMATION,
197
+ SKIP_NO_ATLAS,
198
+ SKIP_NO_ATLAS_PAGE,
199
+ SKIP_NO_ATLAS_REGION,
200
+ SKIP_NO_ATTACHMENT_REGION_JOIN,
201
+ SKIP_NO_DECLARED_DURATION,
202
+ SKIP_NO_LINKED_MESH,
203
+ SKIP_NO_MESH_ATTACHMENT,
204
+ SKIP_NO_PHYSICS_CONSTRAINT,
205
+ SKIP_NO_POSE,
206
+ SKIP_NO_REGION_ATTACHMENT,
207
+ SKIP_NO_SEPARABLE_COLOR,
208
+ SKIP_NO_SEQUENCE,
209
+ SKIP_NO_SKELETON,
210
+ SKIP_NO_TIMELINE,
211
+ SKIP_NO_TWO_COLOR_TINT,
212
+ } from './assertions/reasons.ts';
350
213
 
351
214
  export interface ValidateInput {
352
215
  skeletonText: string;
@@ -613,6 +476,9 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
613
476
  return arm === undefined ? physicsOutsideSays(rule, poseValue) : physicsBasisSays(arm);
614
477
  }
615
478
 
479
+ /** `physicsTimelineNames`' table, once it has been built. */
480
+ let physicsTimelineNamesTable: Record<number, string> | null = null;
481
+
616
482
  /**
617
483
  * The skeleton-JSON name of each physics timeline, keyed by the runtime's own
618
484
  * `Property` id.
@@ -624,16 +490,25 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
624
490
  * on the right are the ones `SkeletonJson`'s physics branch reads
625
491
  * (`SkeletonJson.js:1063-1094`), which is also what a motion spec's `property`
626
492
  * says.
493
+ *
494
+ * ⚠️ Built on first use, not while the module loads (issue #1014): the
495
+ * computed keys read the runtime's `Property` enum, and seven reads of it at
496
+ * load time were the only spine-core access a module made before a command
497
+ * ran — so `--help`, `render` and `check` on a rigc build all touched the
498
+ * runtime for a table only the gate's physics rules consult.
627
499
  */
628
- export const PHYSICS_TIMELINE_NAMES: Record<number, string> = {
629
- [Property.physicsConstraintInertia]: 'inertia',
630
- [Property.physicsConstraintStrength]: 'strength',
631
- [Property.physicsConstraintDamping]: 'damping',
632
- [Property.physicsConstraintMass]: 'mass',
633
- [Property.physicsConstraintWind]: 'wind',
634
- [Property.physicsConstraintGravity]: 'gravity',
635
- [Property.physicsConstraintMix]: 'mix',
636
- };
500
+ export function physicsTimelineNames(): Record<number, string> {
501
+ physicsTimelineNamesTable ??= {
502
+ [Property.physicsConstraintInertia]: 'inertia',
503
+ [Property.physicsConstraintStrength]: 'strength',
504
+ [Property.physicsConstraintDamping]: 'damping',
505
+ [Property.physicsConstraintMass]: 'mass',
506
+ [Property.physicsConstraintWind]: 'wind',
507
+ [Property.physicsConstraintGravity]: 'gravity',
508
+ [Property.physicsConstraintMix]: 'mix',
509
+ };
510
+ return physicsTimelineNamesTable;
511
+ }
637
512
 
638
513
  /**
639
514
  * Which physics constraints a physics timeline that names NO constraint writes
@@ -698,55 +573,30 @@ export function unnamedPhysicsTimeline(name: string): Timeline | null {
698
573
  */
699
574
  const SPINE_4_3: SpineGeneration = '4.3';
700
575
 
701
- type Json = Record<string, unknown>;
702
-
703
- function isObj(v: unknown): v is Json {
704
- return typeof v === 'object' && v !== null && !Array.isArray(v);
705
- }
706
-
707
- /**
708
- * The time to pose a key at so that the runtime is AT it: the later of the
709
- * file's number and that number as spine-core stores it (issue #771).
710
- *
711
- * 🚨 Every timeline keeps its key times in a `Float32Array`
712
- * (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
713
- * stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
714
- * the double `0.2`. Stepped to the file's own number, a timeline is then just
715
- * BEFORE its key: before a first key it writes the setup value (`time <
716
- * frames[0]`), and past a stepped key it still holds the one before
717
- * (`frames[i] > time`). That is a pose one float step from the key, not the
718
- * key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
719
- * 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
720
- *
721
- * ⚠️ The later of the two rather than `Math.fround` alone: where the float
722
- * rounds DOWN, the file's number is already past the stored key, and a runtime
723
- * built without typed arrays stores the double itself — in both, the file's
724
- * number is the one at or after the key. What a key time rounds to is the
725
- * runtime's storage and not the file's statement, so a rule judging what a KEY
726
- * states poses at the key; the rounding itself is nothing an author can repair.
727
- */
728
- function atStoredKey(time: number): number {
729
- return Math.max(time, Math.fround(time));
730
- }
576
+ // `Json`, `isObj` and `atStoredKey` are `./assertions/values.ts`'s since issue
577
+ // #1025: the bodies that moved out of this file read values the same way.
731
578
 
732
579
  /**
733
580
  * The atlas region names one raw skin entry will make the loader look up — or
734
581
  * `null` when the file states a sequence this walk cannot predict.
735
582
  *
736
- * ⚠️ Transcribed from the loader rather than reasoned out, because a join that
583
+ * ⚠️ Measured against the loader rather than reasoned out, because a join that
737
584
  * merely looks right is the thing A08 exists to refuse. `readSequence`
738
585
  * (`dist/SkeletonJson.js:641-649`) turns an absent or null `sequence` into
739
586
  * `new Sequence(1, false)` — one lookup, at the bare path — and a present one
740
587
  * into `new Sequence(count ?? 0, true)`, so a sequence map with no `count`
741
- * looks up nothing at all. `Sequence.getPath` (`dist/Sequence.js:124-132`)
742
- * appends `start + i`, left-padded with zeros to `digits`.
588
+ * looks up nothing at all. Each frame's name is the core's `frameRegionName`
589
+ * (`src/core/uvs.ts`): `start + i`, left-padded with zeros to `digits`, after
590
+ * the path (issue #1015). `PS127` records what the loader asks for and compares,
591
+ * and the core suite holds the frame names to `Sequence.getPath` over 7,680
592
+ * cases.
743
593
  *
744
594
  * Nothing in `examples/` carries a `sequence` (measured: 0 occurrences across
745
595
  * all twelve editor exports), so without this the assertion would have read a
746
596
  * sequence's base path as a region name and refused correct foreign data —
747
597
  * `A21_MESH_RIM_PINNED`'s old `|| 'ring'` default, one file over.
748
598
  */
749
- function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
599
+ export function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
750
600
  if (sequence === undefined || sequence === null) return [path];
751
601
  if (!isObj(sequence)) return null;
752
602
  const whole = (value: unknown, fallback: number): number | null => {
@@ -757,11 +607,9 @@ function attachmentRegionLookups(sequence: unknown, path: string): string[] | nu
757
607
  const start = whole(sequence.start, 1);
758
608
  const digits = whole(sequence.digits, 0);
759
609
  if (count === null || start === null || digits === null || count < 0) return null;
610
+ const series = { count, start, digits, setup: 0 };
760
611
  const lookups: string[] = [];
761
- for (let i = 0; i < count; i++) {
762
- const frame = String(start + i);
763
- lookups.push(`${path}${'0'.repeat(Math.max(0, digits - frame.length))}${frame}`);
764
- }
612
+ for (let i = 0; i < count; i++) lookups.push(frameRegionName(path, series, i));
765
613
  return lookups;
766
614
  }
767
615
 
@@ -870,11 +718,11 @@ interface RawLinkedMesh {
870
718
  * `"<skin>\0<slot>\0<placeholder>" -> source` for every linked mesh the raw
871
719
  * skeleton declares, with what the file says about each one (issues #691, #710).
872
720
  *
873
- * 🔑 The test is the parser's own and it is not `type`: `type: "mesh"` and
874
- * `type: "linkedmesh"` share one branch and a truthy `source` is what decides
875
- * between them (`SkeletonJson.js:568-569`, `:582`). An empty `source` is falsy
876
- * there, so it is not a link here either — that map is read as an ordinary mesh,
877
- * which is exactly what the runtime does with it.
721
+ * 🔑 The format decides it, and not by `type`: `type: "mesh"` and
722
+ * `type: "linkedmesh"` share one branch of the reader and a truthy `source` is
723
+ * what decides between them (`SkeletonJson.js:568-569`, `:582`). An empty
724
+ * `source` is falsy there, so it is not a link here either — that map is read
725
+ * as an ordinary mesh, which is exactly what the runtime does with it.
878
726
  */
879
727
  function rawLinkedMeshes(raw: unknown): Map<string, RawLinkedMesh> {
880
728
  const links = new Map<string, RawLinkedMesh>();
@@ -1142,51 +990,92 @@ function firstDifferingLine(first: string, second: string): string {
1142
990
  return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
1143
991
  }
1144
992
 
993
+ /**
994
+ * The runtime's supply of `SkinEntryFacts` (issue #1025): every skin of the
995
+ * loaded skeleton in the loaded order, and each skin's entries as
996
+ * `getAttachments()` lists them — the walk this file's prelude makes for its
997
+ * own lists, made once more here so the facts the moved bodies read are a
998
+ * value the selftest can compare with the model side's, order included.
999
+ */
1000
+ export function spineSkinEntries(data: ReturnType<SkeletonJson['readSkeletonData']>): SkinEntryFacts {
1001
+ const regionAttachments: RegionAttachment[] = [];
1002
+ let clippingCount = 0;
1003
+ for (const skin of data.skins) {
1004
+ for (const entry of skin.getAttachments()) {
1005
+ if (entry.attachment instanceof RegionAttachment) regionAttachments.push(entry.attachment);
1006
+ else if (entry.attachment instanceof ClippingAttachment) clippingCount++;
1007
+ }
1008
+ }
1009
+ return { regionAttachments, clippingCount };
1010
+ }
1011
+
1012
+ /**
1013
+ * The facts the moved bodies read, as `validate()` supplies them from a pair
1014
+ * spine-core loads — or `null` when the load throws, which is A00's failure
1015
+ * and `validate()`'s to name. For the selftest and `tools/verdict_gate.ts`,
1016
+ * which compare these with the model side's fact by fact, where a verdict
1017
+ * line would hide a difference (an order nothing failed on, a name no line
1018
+ * printed); `validate()` itself builds them from its own round trip.
1019
+ */
1020
+ export function runtimeFacts(skeletonText: string, atlasText: string): { skinEntries: SkinEntryFacts; atlasPages: AtlasPageFacts; slotColour: SlotColourFacts } | null {
1021
+ let atlas: TextureAtlas;
1022
+ let data: ReturnType<SkeletonJson['readSkeletonData']>;
1023
+ try {
1024
+ atlas = new TextureAtlas(atlasText);
1025
+ data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(JSON.parse(skeletonText));
1026
+ } catch {
1027
+ return null;
1028
+ }
1029
+ return { skinEntries: spineSkinEntries(data), atlasPages: { atlas }, slotColour: spineSlotColourFacts(JSON.parse(skeletonText) as Json, data) };
1030
+ }
1031
+
1032
+ /**
1033
+ * The runtime's supply of A45's facts (issue #1025): the slot timelines read
1034
+ * off the skeleton JSON in the file's order — every animation the file keys,
1035
+ * every slot an animation keys, skipping what is not an object, as A45's walk
1036
+ * always read them — whether the loaded skeleton holds an animation, and a
1037
+ * slot's colour posed on a fresh, non-looping track by spine-core: setup pose,
1038
+ * `update(0)`, `updateWorldTransform(Physics.reset)`, then the track stepped to
1039
+ * the time and applied. The model side's supply is
1040
+ * `./assertions/model/slot_colour.ts`; the selftest holds the two to the same
1041
+ * lines.
1042
+ */
1043
+ function spineSlotColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SlotColourFacts {
1044
+ const slotTimelines: SlotTimelines[] = [];
1045
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
1046
+ for (const [animation, anim] of Object.entries(rawAnimations)) {
1047
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
1048
+ for (const [slot, timelines] of Object.entries(anim.slots)) {
1049
+ if (!isObj(timelines)) continue;
1050
+ slotTimelines.push({ animation, slot, timelines });
1051
+ }
1052
+ }
1053
+ return {
1054
+ slotTimelines,
1055
+ hasAnimation: (name) => Boolean(data.findAnimation(name)),
1056
+ posedSlot: (animName, slotName, time) => {
1057
+ const skeleton = new Skeleton(data);
1058
+ const state = new AnimationState(new AnimationStateData(data));
1059
+ state.setAnimation(0, animName, false);
1060
+ skeleton.setupPose();
1061
+ skeleton.update(0);
1062
+ skeleton.updateWorldTransform(Physics.reset);
1063
+ state.update(time);
1064
+ state.apply(skeleton);
1065
+ return skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
1066
+ },
1067
+ };
1068
+ }
1069
+
1145
1070
  export function validate(input: ValidateInput): ValidateReport {
1146
- const failures: Failure[] = [];
1147
- const passed: string[] = [];
1148
- const skipped: ValidateReport['skipped'] = [];
1149
- const profileSkipped: ValidateReport['profileSkipped'] = [];
1150
- const stats: Record<string, number | string> = {};
1151
1071
  const profile = input.profile;
1152
1072
  /** True when this profile's rulebook includes the policy layer. */
1153
1073
  const policy = profile === 'spine-html';
1154
-
1155
- const fail = (assertion: string, detail: string) => failures.push({ assertion, detail });
1156
- /** Declare that an assertion had nothing to check, and why. */
1157
- const skip = (assertion: string, reason: string) => skipped.push({ assertion, reason });
1158
- /**
1159
- * Run one assertion; record it as passed only if it neither failed nor skipped,
1160
- * and do not run it at all when the profile does not carry that kind of rule.
1161
- *
1162
- * The unknown-assertion throw is deliberate: an assertion with no entry in
1163
- * ASSERTION_KIND would otherwise pick a profile by accident, and picking wrong
1164
- * means either a rule that never runs or a rule that fires on everybody's data.
1165
- *
1166
- * The body's return value is handed back — `undefined` when the assertion did
1167
- * not run or threw. Only A00 uses it, and it uses it so that the loaded atlas
1168
- * and skeleton can be `const`: assigning them from inside this callback leaves
1169
- * the type checker unable to see that they were ever set, and every later read
1170
- * of `atlas.pages` or `data.bones` becomes a property of `never`.
1171
- */
1172
- const check = <T>(assertion: string, body: () => T): T | undefined => {
1173
- const kind = ASSERTION_KIND[assertion];
1174
- if (!kind) throw new Error(`validate: assertion "${assertion}" has no ASSERTION_KIND entry`);
1175
- if (kind !== 'validity' && !policy) {
1176
- profileSkipped.push({ assertion, kind });
1177
- return undefined;
1178
- }
1179
- const before = failures.length;
1180
- const skippedBefore = skipped.length;
1181
- let result: T | undefined;
1182
- try {
1183
- result = body();
1184
- } catch (err) {
1185
- fail(assertion, `threw: ${(err as Error).message}`);
1186
- }
1187
- if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
1188
- return result;
1189
- };
1074
+ // The harness — `check`, `fail`, `skip` and the lists they fill — is
1075
+ // `./assertions/harness.ts`'s since issue #1025, and the rule is the one it
1076
+ // always was: the model side runs the bodies that moved there in the same
1077
+ // harness, so a verdict cannot be decided two ways.
1078
+ const { failures, passed, skipped, profileSkipped, stats, fail, skip, check, verdicts } = verdictHarness(profile, ASSERTION_KIND, 'validate');
1190
1079
 
1191
1080
  // -------------------------------------------------------------------------
1192
1081
  // Raw text / raw JSON assertions (they must run even if the parser is happy)
@@ -2016,7 +1905,6 @@ export function validate(input: ValidateInput): ValidateReport {
2016
1905
 
2017
1906
  const regionAttachments: RegionAttachment[] = [];
2018
1907
  const meshAttachments: MeshAttachment[] = [];
2019
- let clippingCount = 0;
2020
1908
  const meshSlots = new Set<number>();
2021
1909
  /**
2022
1910
  * The loaded mesh of every attachment the FILE spells as a link, to what the
@@ -2063,26 +1951,18 @@ export function validate(input: ValidateInput): ValidateReport {
2063
1951
  linkedMeshes.set(att, link);
2064
1952
  loadedLinks.set(join, att);
2065
1953
  }
2066
- } else if (att instanceof ClippingAttachment) clippingCount++;
1954
+ }
2067
1955
  }
2068
1956
  }
2069
1957
  stats.regionAttachments = regionAttachments.length;
2070
1958
  stats.meshAttachments = meshAttachments.length;
1959
+ // What the bodies that moved to `./assertions/bodies/` read of the skins —
1960
+ // the runtime's supply of `SkinEntryFacts`, in the loaded skins' own order
1961
+ // (issue #1025).
1962
+ const skinEntries = spineSkinEntries(data);
2071
1963
 
2072
1964
  // --- A03: every region has finite width/height (case 6c) ---------------
2073
- check('A03_REGION_WIDTH_HEIGHT_FINITE', () => {
2074
- // ⟨subject⟩_⟨property⟩: with no region there is no size to find non-finite,
2075
- // and a loop over nothing used to report that as held (#580).
2076
- if (regionAttachments.length === 0) return skip('A03_REGION_WIDTH_HEIGHT_FINITE', SKIP_NO_REGION_ATTACHMENT);
2077
- for (const att of regionAttachments) {
2078
- if (!Number.isFinite(att.width) || !Number.isFinite(att.height)) {
2079
- fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" loaded w=${att.width} h=${att.height}`);
2080
- }
2081
- if (att.width <= 0 || att.height <= 0) {
2082
- fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" has a non-positive size`);
2083
- }
2084
- }
2085
- });
1965
+ check('A03_REGION_WIDTH_HEIGHT_FINITE', () => a03RegionWidthHeightFinite(verdicts, skinEntries));
2086
1966
 
2087
1967
  // --- A04: mesh triangles + encoding coherence (case 6f) ----------------
2088
1968
  check('A04_MESH_TRIANGLES_AND_ENCODING', () => {
@@ -2277,11 +2157,7 @@ export function validate(input: ValidateInput): ValidateReport {
2277
2157
  });
2278
2158
 
2279
2159
  // --- A11 / A13 / A14: renderer + canvas budgets ----
2280
- check('A11_NO_CLIPPING_ATTACHMENTS', () => {
2281
- if (clippingCount > 0) {
2282
- fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
2283
- }
2284
- });
2160
+ check('A11_NO_CLIPPING_ATTACHMENTS', () => a11NoClippingAttachments(verdicts, skinEntries));
2285
2161
  // 📐 The two numbers come from the rig spec's `invariants`, never from here.
2286
2162
  // A mesh budget is one consumer's frame time written down — the editor's own
2287
2163
  // example projects ship meshes many times denser and they are valid — so a
@@ -3305,7 +3181,7 @@ export function validate(input: ValidateInput): ValidateReport {
3305
3181
  const reached = keyedLive(
3306
3182
  (timeline): timeline is PhysicsConstraintTimeline =>
3307
3183
  timeline instanceof PhysicsConstraintTimeline &&
3308
- PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
3184
+ physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
3309
3185
  (timeline, value) => {
3310
3186
  timeline.set(raised, value);
3311
3187
  return rule.poseOk(raised[rule.field]);
@@ -3388,7 +3264,7 @@ export function validate(input: ValidateInput): ValidateReport {
3388
3264
  // `ConstraintTimeline1` encodes its propertyId as `<Property>|<index>`,
3389
3265
  // so the name comes from the runtime's own enum rather than from a
3390
3266
  // table of strings this file would have to keep in step.
3391
- const name = PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])];
3267
+ const name = physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])];
3392
3268
  if (name === undefined) continue;
3393
3269
  const rule = physicsRuleFor(name);
3394
3270
  // -1 is the global form: the timeline drives every physics constraint
@@ -4782,174 +4658,10 @@ export function validate(input: ValidateInput): ValidateReport {
4782
4658
 
4783
4659
  // --- A45: the separable colour timelines own their channels ------------
4784
4660
  //
4785
- // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
4786
- // `alpha` pose no dark colour, so under A43's name a failure would be a
4787
- // verdict saying "two-colour tint" about a file that states one colour —
4788
- // the name would say something other than what it decides (the rule #712
4789
- // applied to A43 itself, against a clause on A10). And the SKIP is the
4790
- // sharper half: a rig with a `dark` and no separable timeline has A43's
4791
- // subject present, so a clause there could only PASS on it, which is a pass
4792
- // for an absent subject.
4793
- //
4794
- // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
4795
- // clauses are about the file against the runtime:
4796
- //
4797
- // 1. **One channel, one timeline.** Each colour timeline poses its
4798
- // channels at EVERY time — before its first key it writes the setup
4799
- // value — so when two of one slot share a channel, the one applied
4800
- // later (the one the file states later) overwrites the other
4801
- // everywhere and the first one's keys on it are read by nothing
4802
- // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
4803
- // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
4804
- // loads without a word.
4805
- // 2. **Posed as written.** Stepped to each key's own time, the channels
4806
- // the timeline writes are the key's: a colour that is not six hex
4807
- // digits loads as NaN, and a key whose time another key repeats is
4808
- // read by nothing, and both parse in silence.
4809
- //
4810
- // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
4811
- // setup alpha is NOT this rule's to see, and nothing that reads only the
4812
- // file can see it: the result is a correct `rgba`, which is also what an
4813
- // author keying the light colour and holding its alpha would write. What
4814
- // differs is what happens UNDER another track that moves the alpha, which
4815
- // is the consumer's composing rather than the object (CLAUDE.md). That
4816
- // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
4817
- //
4818
- // ⚠️ The required values are parsed HERE, as A43's are, and the channel
4819
- // table is `timelines.ts`'s rather than the loaded timelines' property ids:
4820
- // a check that asked the parser what a timeline writes would agree with it
4821
- // whatever it did. A selftest control holds that table to the runtime's ids.
4822
- check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => {
4823
- const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
4824
- /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
4825
- const readRgbHex = (hex: unknown): [number, number, number] | null => {
4826
- if (typeof hex !== 'string') return null;
4827
- const body = hex.startsWith('#') ? hex.slice(1) : hex;
4828
- if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
4829
- return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
4830
- };
4831
- /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
4832
- const HEX_STEP = 1 / 510;
4833
- const FLOAT_STEP = 1e-6;
4834
- const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
4835
- const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
4836
-
4837
- // -- the subjects, read off the FILE ---------------------------------
4838
- const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
4839
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
4840
- for (const [animName, anim] of Object.entries(rawAnimations)) {
4841
- if (!isObj(anim) || !isObj(anim.slots)) continue;
4842
- for (const [slotName, timelines] of Object.entries(anim.slots)) {
4843
- if (!isObj(timelines)) continue;
4844
- if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
4845
- subjects.push({ anim: animName, slot: slotName, timelines });
4846
- }
4847
- }
4848
- }
4849
- if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
4850
- stats.separableColorTimelines = subjects.reduce(
4851
- (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
4852
- 0,
4853
- );
4854
-
4855
- for (const { anim: animName, slot: slotName, timelines } of subjects) {
4856
- const at = `animation "${animName}" slot "${slotName}"`;
4857
- // In FILE order, which is the order `readAnimation` pushes them and so
4858
- // the order they apply in.
4859
- const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
4860
-
4861
- // -- clause 1: one channel, one timeline ---------------------------
4862
- let shared = false;
4863
- for (const channel of ['rgb', 'alpha', 'dark'] as const) {
4864
- const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
4865
- if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
4866
- shared = true;
4867
- const last = writers[writers.length - 1];
4868
- fail(
4869
- NAME,
4870
- `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
4871
- `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
4872
- `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
4873
- 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
4874
- );
4875
- }
4876
- if (shared) continue;
4877
-
4878
- // -- clause 2: posed as written ------------------------------------
4879
- //
4880
- // ⚠️ Two things this does NOT do, both measured rather than skipped:
4881
- // it does not read the loaded timeline's CLASS back, and it does not
4882
- // hold a channel the timeline leaves alone to the setup pose. Against
4883
- // the linked parser neither can fail — every `rgb` / `alpha` array that
4884
- // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
4885
- // three other outcomes (an empty array, a slot the skeleton lacks, a
4886
- // name outside the switch) throw at `A00` — so either would be a clause
4887
- // nobody can see fire. The selftest reads both off spine-core directly
4888
- // (`S82`–`S84`), where they are measurements of the runtime rather than
4889
- // checks on a file.
4890
- if (!data.findAnimation(animName)) {
4891
- fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
4892
- continue;
4893
- }
4894
- for (const name of ['rgb', 'alpha'] as const) {
4895
- const keys = timelines[name];
4896
- if (!Array.isArray(keys)) continue;
4897
- const where = `${at} ${name}`;
4898
- for (const rawKey of keys) {
4899
- if (!isObj(rawKey)) continue;
4900
- const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
4901
- const skeleton = new Skeleton(data);
4902
- const state = new AnimationState(new AnimationStateData(data));
4903
- state.setAnimation(0, animName, false);
4904
- skeleton.setupPose();
4905
- skeleton.update(0);
4906
- skeleton.updateWorldTransform(Physics.reset);
4907
- // At the key as the runtime stores it, not one float step before
4908
- // it — see `atStoredKey` (issue #771).
4909
- state.update(atStoredKey(time));
4910
- state.apply(skeleton);
4911
- const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
4912
- if (!posed) {
4913
- fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
4914
- continue;
4915
- }
4916
- const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
4917
- if (name === 'rgb') {
4918
- const stated = readRgbHex(rawKey.color);
4919
- if (stated === null) {
4920
- fail(
4921
- NAME,
4922
- `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
4923
- `digits — and the runtime poses (${show(light.slice(0, 3))})`,
4924
- );
4925
- } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
4926
- fail(
4927
- NAME,
4928
- `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
4929
- `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
4930
- );
4931
- }
4932
- } else {
4933
- // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
4934
- // omits it there, so absence is read the parser's way rather than
4935
- // as a malformed key.
4936
- const stated = rawKey.value === undefined ? 0 : rawKey.value;
4937
- if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
4938
- fail(
4939
- NAME,
4940
- `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
4941
- (typeof stated !== 'number'
4942
- ? ' — an alpha key\'s value is a number'
4943
- : stated < 0 || stated > 1
4944
- ? ' — the runtime clamps a posed alpha to 0..1'
4945
- : ''),
4946
- );
4947
- }
4948
- }
4949
- }
4950
- }
4951
- }
4952
- });
4661
+ // The body, and the argument for the rule, are `./assertions/bodies/a45.ts`
4662
+ // since issue #1025: the same body runs over the model document. What it
4663
+ // reads off spine-core's loaded skeleton is `spineSlotColourFacts`.
4664
+ check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten(verdicts, spineSlotColourFacts(raw, data)));
4953
4665
 
4954
4666
  // --- A44: a linked mesh states no geometry of its own ------------------
4955
4667
  //
@@ -5029,9 +4741,9 @@ export function validate(input: ValidateInput): ValidateReport {
5029
4741
  // reads each of those off the FILE and names the value. The second POSES
5030
4742
  // every key at sampled times — mid-frame, so a float32 key time cannot land
5031
4743
  // a sample on a frame boundary — and compares the region the slot shows
5032
- // against the one the file's own statement gives: the frame arithmetic of
5033
- // `SequenceTimeline.applyToSlot` transcribed here, and the frame names of
5034
- // `Sequence.getPath` transcribed in `attachmentRegionLookups`. Neither is
4744
+ // against the one the file's own statement gives: the frame the key's mode,
4745
+ // index and delay give (`frameOf` below), and the frame names
4746
+ // `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
5035
4747
  // read off the loaded timeline, so the check is not the runtime agreeing
5036
4748
  // with itself.
5037
4749
  //
@@ -5099,7 +4811,15 @@ export function validate(input: ValidateInput): ValidateReport {
5099
4811
  }
5100
4812
  }
5101
4813
 
5102
- /** The frame `SequenceTimeline.applyToSlot` shows — transcribed, not called. */
4814
+ /**
4815
+ * The frame the file's statement gives for one key at one elapsed time —
4816
+ * A46's prediction, not a call into the runtime. What holds it is the
4817
+ * comparison it feeds: every sample sets it against the frame the posed
4818
+ * slot shows, so a reading that disagreed with the runtime would fail A46
4819
+ * on a correct file. A46 passing with samples posed on the selftest's
4820
+ * series probes is that measurement for the modes they key; `M73` is its
4821
+ * red half.
4822
+ */
5103
4823
  const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
5104
4824
  if (mode === 'hold') return index;
5105
4825
  let i = index + Math.trunc(elapsed / delay + 0.00001);
@@ -5295,17 +5015,7 @@ export function validate(input: ValidateInput): ValidateReport {
5295
5015
  // green build had ever contained one; making that state legal is exactly what
5296
5016
  // #608 does, so the same state has to stop printing four passes about nothing.
5297
5017
  // `SKIP_NO_ATLAS_PAGE` is one string for all four because it is one condition.
5298
- check('A17_ATLAS_PAGE_FILES_EXIST', () => {
5299
- if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
5300
- // The other absence, one ring in (#580): the atlas LOADED and declares no
5301
- // page, so there is no file to find missing. `!atlas` and "zero pages" print
5302
- // different reasons because they are different facts about the artifact.
5303
- if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
5304
- for (const page of atlas.pages) {
5305
- const abs = resolve(input.atlasDir, page.name);
5306
- if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
5307
- }
5308
- });
5018
+ check('A17_ATLAS_PAGE_FILES_EXIST', () => a17AtlasPageFilesExist(verdicts, { atlas }, input));
5309
5019
  check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
5310
5020
  if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
5311
5021
  if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
@@ -5436,7 +5146,7 @@ export function validate(input: ValidateInput): ValidateReport {
5436
5146
  // is about rigc's own packer never turning a region, which is a statement
5437
5147
  // about what rigc WRITES. It stopped being a statement about what rigc can
5438
5148
  // read in issue #570 — `extractRegion` now lifts a rotated region back off
5439
- // its page as a transcription of `MeshAttachment.computeUVs` — and the two
5149
+ // its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
5440
5150
  // must not be re-merged: an artifact under the renderer's own rulebook that
5441
5151
  // rigc did not pack is still a foreign artifact, whatever rigc can measure.
5442
5152
  const regionsPerPage = new Map<string, TextureAtlasRegion[]>();
@@ -6214,7 +5924,7 @@ export function validate(input: ValidateInput): ValidateReport {
6214
5924
  for (const name of ASSERTION_NAMES) {
6215
5925
  if (reported.has(name)) continue;
6216
5926
  const kind = ASSERTION_KIND[name];
6217
- if (kind !== 'validity' && !policy) profileSkipped.push({ assertion: name, kind });
5927
+ if (kind !== 'validity' && !kindRunsUnder(kind, profile)) profileSkipped.push({ assertion: name, kind });
6218
5928
  else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
6219
5929
  else {
6220
5930
  fail(