spine-rigc 1.7.0 → 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.
package/src/validate.ts CHANGED
@@ -100,11 +100,34 @@ import {
100
100
  walkTimelines,
101
101
  } from './timelines.ts';
102
102
  import type { RigInfo } from './types.ts';
103
-
104
- export interface Failure {
105
- assertion: string;
106
- detail: string;
107
- }
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';
108
131
 
109
132
  /**
110
133
  * Which body of rules to hold the artifact to.
@@ -130,7 +153,7 @@ export interface Failure {
130
153
  * no longer honestly serve both — so the choice is made at every call site now,
131
154
  * by the caller who knows which question they are asking.
132
155
  */
133
- export type ValidateProfile = 'spine' | 'spine-html';
156
+ export type ValidateProfile = AssertionProfile;
134
157
 
135
158
  export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
136
159
 
@@ -147,84 +170,8 @@ export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-ht
147
170
  */
148
171
  export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
149
172
 
150
- /**
151
- * What kind of rule each assertion is. Every assertion has an entry, and
152
- * `check()` throws on a name that has none — a new assertion must state its kind
153
- * rather than defaulting into one, because the default would decide, silently,
154
- * whether it runs on foreign data.
155
- *
156
- * validity — the file is wrong for any consumer. Runs under every profile.
157
- * renderer — valid Spine that this project's renderer or frame budget refuses.
158
- * archetype — a structural rule about rigc's own formations, meaningless to a
159
- * skeleton rigc did not compile.
160
- *
161
- * Two assertions are MIXED and are marked `validity` here because their
162
- * validity half must never stop running; their policy clauses are gated inside
163
- * the assertion body against `profile`, and each such clause says so where it
164
- * lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
165
- * it names are validity; pma / rotation / two regions over the same texels are
166
- * policy — the rectangle moved across that line in issue #694, and the reason
167
- * is stated where it now sits) and A20 (weight coherence is validity; requiring
168
- * a mesh to be weighted at all is policy).
169
- *
170
- * A08 was the third until issue #574 retired its policy clause. It required a
171
- * skin entry's placeholder to be spelled like the region it resolves to, under
172
- * a renderer that resolves art by `path` and has never read a placeholder — so
173
- * restating it as the renderer's own join made it a tautology over the validity
174
- * half. The argument, with the renderer lines it is measured against, sits above
175
- * `check('A08_…')`.
176
- */
177
- const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
178
- A00_ROUNDTRIP_PARSE: 'validity',
179
- A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
180
- A02_NO_BONE_TRANSFORM_KEY: 'validity',
181
- A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
182
- A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
183
- A05_CURVE_ARRAY_LENGTH: 'validity',
184
- A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
185
- A07_ATLAS_TEXT_SHAPE: 'validity',
186
- A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
187
- A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
188
- A10_NO_NAN_AFTER_STEPPING: 'validity',
189
- A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
190
- A12_NO_DARK_COLOR: 'renderer',
191
- A13_MESH_BUDGET: 'renderer',
192
- A14_NO_FULL_FRAME_MESH: 'renderer',
193
- A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
194
- A16_SKELETON_VERSION_4_3: 'validity',
195
- A17_ATLAS_PAGE_FILES_EXIST: 'validity',
196
- A18_DETERMINISTIC_EMIT: 'validity',
197
- A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
198
- A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
199
- A21_MESH_RIM_PINNED: 'archetype',
200
- A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
201
- A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
202
- A24_AXIS_SPACE_STROKE: 'archetype',
203
- A25_DETACHED_BONE_PARENTAGE: 'archetype',
204
- A26_SLOT_DRAW_ORDER: 'archetype',
205
- A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
206
- A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
207
- A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
208
- A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
209
- A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
210
- A32_EVENT_KEYS_RESOLVE: 'validity',
211
- A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
212
- A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
213
- A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
214
- A36_PATH_CONSTRAINT_EFFECTIVE: 'validity',
215
- A37_SLIDER_CONSTRAINT_EFFECTIVE: 'validity',
216
- A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
217
- A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
218
- A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
219
- A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
220
- A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
221
- A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
222
- A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
223
- A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
224
- A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
225
- A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
226
- A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
227
- };
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.
228
175
 
229
176
  /**
230
177
  * Every assertion this validator knows, in registry order.
@@ -242,112 +189,27 @@ export function assertionCountForProfile(profile: ValidateProfile): number {
242
189
  return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
243
190
  }
244
191
 
245
- /**
246
- * What a SKIP says when `A00_ROUNDTRIP_PARSE` handed an assertion nothing to
247
- * look at.
248
- *
249
- * Two of them rather than one, because what an assertion was denied is the whole
250
- * content of its SKIP: `A20` wanted the loaded skeleton and `A06` wanted the
251
- * loaded atlas, and a reader who is told which can tell a rule that is waiting
252
- * on the parse from one that is waiting on the art. Both point at A00 instead of
253
- * restating its detail, which the FAIL row prints in full.
254
- *
255
- * Exported so a control compares against them rather than quoting them — the
256
- * same reason `ASSERTION_NAMES` is exported.
257
- */
258
- export const SKIP_NO_SKELETON = 'the round trip did not produce a skeleton to measure (A00 owns that failure)';
259
- export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure (A00 owns that failure)';
260
- /**
261
- * The third of them, and it is NOT waiting on A00 (issue #608): the atlas parsed
262
- * and it has no pages, which is what a compile that measured no art writes. The
263
- * four rules whose only subject is a page — A06, A17, A19, A27 — have nothing to
264
- * look at, and an empty loop walking out of `check()` is reported as a PASS.
265
- */
266
- /*
267
- * **An empty subject list: SKIP or PASS, and which one is derived (issue #580).**
268
- *
269
- * `check()` records a pass when a body runs to its end without a `fail()` or a
270
- * `skip()`, so a body whose main construct is a loop over a list that is EMPTY
271
- * passes having measured nothing — the vacuous green one ring out from the bare
272
- * `return` guards issue #568 converted. Two different things can be true of such
273
- * a loop, and the criterion decides between them by reading the rule's own name
274
- * and its `fail()` sentences rather than by preference:
275
- *
276
- * * A name of the form ⟨subject⟩_⟨property⟩ — `REGION_WIDTH_HEIGHT_FINITE`,
277
- * `MESH_TRIANGLES_AND_ENCODING`, `PHYSICS_CONSTRAINT_EFFECTIVE`,
278
- * `ATLAS_PAGE_SIZE_MATCHES_PNG` — quantifies over the subject it names, and
279
- * its `fail()` names a member of that subject, the value found and the value
280
- * required. With no member, nothing was measured: it **SKIPs**, and the
281
- * reason names the subject that was absent.
282
- * * A name of the form NO_⟨construct⟩ — `NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS`,
283
- * `NO_BONE_TRANSFORM_KEY`, `NO_CLIPPING_ATTACHMENTS`, `NO_DARK_COLOR`,
284
- * `NO_FULL_FRAME_MESH` — quantifies over occurrences of something a correct
285
- * artifact has NONE of, and its `fail()` reports that the construct is
286
- * present rather than measuring a value on it. Zero occurrences IS the
287
- * measurement, so it **PASSes**: the loop is a search of the artifact, and
288
- * the artifact is what it measured.
289
- * * A name carrying both halves takes each at its word.
290
- * `A15_IDLE_NO_MESH_BONE_KEYS` is the pattern and already reads this way: no
291
- * `idle` animation, or an `idle` with no bone timeline, is the named subject
292
- * absent and SKIPs; no mesh-driving bone among the bones `idle` does key is
293
- * the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` was the same
294
- * shape until issue #902, and is the next bullet's now: #882 gave it a
295
- * setup-pose clause whose subject is the bones, beside the stepping clause
296
- * whose subject is the animations.
297
- * * An assertion with more than one clause SKIPs only when EVERY clause had
298
- * nothing to measure, which is the shape `A09`, `A33` and `A38` already
299
- * carry (`polygons.length === 0 && endsChecked === 0`), and `A10` since #902.
300
- * `A13_MESH_BUDGET` is why the clause is stated: a declared slot budget is
301
- * measured against a count of mesh slots, and zero is a count, so that half
302
- * passes on a rig with no mesh — while a rig that declares only a TRIANGLE
303
- * budget has nothing left to measure and skips.
304
- *
305
- * ⚠️ What the criterion is not allowed to become is a table of assertions with
306
- * their verdicts written beside them. Every row above is decided by reading the
307
- * name and the sentences the assertion already prints, so a rule added tomorrow
308
- * is decided by the same reading and nobody re-opens this question.
309
- */
310
-
311
- /**
312
- * What a SKIP says when the artifact carries no member of the subject a rule
313
- * quantifies over (issue #580), by subject.
314
- *
315
- * One constant per SUBJECT rather than one per assertion, for the reason the two
316
- * above are two: what the rule was denied is the whole content of its SKIP, and
317
- * three rules denied the same thing should say so in the same words. Exported so
318
- * a control compares against them rather than quoting them.
319
- */
320
- export const SKIP_NO_REGION_ATTACHMENT = 'the skeleton carries no region attachment';
321
- export const SKIP_NO_MESH_ATTACHMENT = 'the skeleton carries no mesh attachment';
322
- export const SKIP_NO_ANIMATION = 'the skeleton carries no animation';
323
- /**
324
- * A10's (issue #902): its setup clause reads the bones and its stepping clause
325
- * the animations, so it skips only when the skeleton carries neither — and the
326
- * sentence is `SKIP_NO_ANIMATION`'s with the second subject added, so the two
327
- * cannot drift into different words for the same absence.
328
- */
329
- export const SKIP_NO_POSE = `${SKIP_NO_ANIMATION} and no bone, so there is no setup pose to read and nothing to step`;
330
- export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
331
- export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
332
- export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
333
- export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
334
- export const SKIP_NO_ATTACHMENT_REGION_JOIN =
335
- 'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
336
- export const SKIP_NO_TWO_COLOR_TINT =
337
- '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';
338
- export const SKIP_NO_SEPARABLE_COLOR =
339
- 'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
340
- export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
341
- export const SKIP_NO_SEQUENCE =
342
- 'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
343
- /**
344
- * A09's, which predates this list and joins it rather than being rewritten: it
345
- * is the same fact about the same subject, and a control that compares against
346
- * eight constants and quotes the ninth is a control with a hand-kept exception
347
- * in it.
348
- */
349
- export const SKIP_NO_DECLARED_DURATION =
350
- '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';
351
213
 
352
214
  export interface ValidateInput {
353
215
  skeletonText: string;
@@ -711,36 +573,8 @@ export function unnamedPhysicsTimeline(name: string): Timeline | null {
711
573
  */
712
574
  const SPINE_4_3: SpineGeneration = '4.3';
713
575
 
714
- type Json = Record<string, unknown>;
715
-
716
- function isObj(v: unknown): v is Json {
717
- return typeof v === 'object' && v !== null && !Array.isArray(v);
718
- }
719
-
720
- /**
721
- * The time to pose a key at so that the runtime is AT it: the later of the
722
- * file's number and that number as spine-core stores it (issue #771).
723
- *
724
- * 🚨 Every timeline keeps its key times in a `Float32Array`
725
- * (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
726
- * stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
727
- * the double `0.2`. Stepped to the file's own number, a timeline is then just
728
- * BEFORE its key: before a first key it writes the setup value (`time <
729
- * frames[0]`), and past a stepped key it still holds the one before
730
- * (`frames[i] > time`). That is a pose one float step from the key, not the
731
- * key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
732
- * 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
733
- *
734
- * ⚠️ The later of the two rather than `Math.fround` alone: where the float
735
- * rounds DOWN, the file's number is already past the stored key, and a runtime
736
- * built without typed arrays stores the double itself — in both, the file's
737
- * number is the one at or after the key. What a key time rounds to is the
738
- * runtime's storage and not the file's statement, so a rule judging what a KEY
739
- * states poses at the key; the rounding itself is nothing an author can repair.
740
- */
741
- function atStoredKey(time: number): number {
742
- return Math.max(time, Math.fround(time));
743
- }
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.
744
578
 
745
579
  /**
746
580
  * The atlas region names one raw skin entry will make the loader look up — or
@@ -1156,51 +990,92 @@ function firstDifferingLine(first: string, second: string): string {
1156
990
  return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
1157
991
  }
1158
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
+
1159
1070
  export function validate(input: ValidateInput): ValidateReport {
1160
- const failures: Failure[] = [];
1161
- const passed: string[] = [];
1162
- const skipped: ValidateReport['skipped'] = [];
1163
- const profileSkipped: ValidateReport['profileSkipped'] = [];
1164
- const stats: Record<string, number | string> = {};
1165
1071
  const profile = input.profile;
1166
1072
  /** True when this profile's rulebook includes the policy layer. */
1167
1073
  const policy = profile === 'spine-html';
1168
-
1169
- const fail = (assertion: string, detail: string) => failures.push({ assertion, detail });
1170
- /** Declare that an assertion had nothing to check, and why. */
1171
- const skip = (assertion: string, reason: string) => skipped.push({ assertion, reason });
1172
- /**
1173
- * Run one assertion; record it as passed only if it neither failed nor skipped,
1174
- * and do not run it at all when the profile does not carry that kind of rule.
1175
- *
1176
- * The unknown-assertion throw is deliberate: an assertion with no entry in
1177
- * ASSERTION_KIND would otherwise pick a profile by accident, and picking wrong
1178
- * means either a rule that never runs or a rule that fires on everybody's data.
1179
- *
1180
- * The body's return value is handed back — `undefined` when the assertion did
1181
- * not run or threw. Only A00 uses it, and it uses it so that the loaded atlas
1182
- * and skeleton can be `const`: assigning them from inside this callback leaves
1183
- * the type checker unable to see that they were ever set, and every later read
1184
- * of `atlas.pages` or `data.bones` becomes a property of `never`.
1185
- */
1186
- const check = <T>(assertion: string, body: () => T): T | undefined => {
1187
- const kind = ASSERTION_KIND[assertion];
1188
- if (!kind) throw new Error(`validate: assertion "${assertion}" has no ASSERTION_KIND entry`);
1189
- if (kind !== 'validity' && !policy) {
1190
- profileSkipped.push({ assertion, kind });
1191
- return undefined;
1192
- }
1193
- const before = failures.length;
1194
- const skippedBefore = skipped.length;
1195
- let result: T | undefined;
1196
- try {
1197
- result = body();
1198
- } catch (err) {
1199
- fail(assertion, `threw: ${(err as Error).message}`);
1200
- }
1201
- if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
1202
- return result;
1203
- };
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');
1204
1079
 
1205
1080
  // -------------------------------------------------------------------------
1206
1081
  // Raw text / raw JSON assertions (they must run even if the parser is happy)
@@ -2030,7 +1905,6 @@ export function validate(input: ValidateInput): ValidateReport {
2030
1905
 
2031
1906
  const regionAttachments: RegionAttachment[] = [];
2032
1907
  const meshAttachments: MeshAttachment[] = [];
2033
- let clippingCount = 0;
2034
1908
  const meshSlots = new Set<number>();
2035
1909
  /**
2036
1910
  * The loaded mesh of every attachment the FILE spells as a link, to what the
@@ -2077,26 +1951,18 @@ export function validate(input: ValidateInput): ValidateReport {
2077
1951
  linkedMeshes.set(att, link);
2078
1952
  loadedLinks.set(join, att);
2079
1953
  }
2080
- } else if (att instanceof ClippingAttachment) clippingCount++;
1954
+ }
2081
1955
  }
2082
1956
  }
2083
1957
  stats.regionAttachments = regionAttachments.length;
2084
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);
2085
1963
 
2086
1964
  // --- A03: every region has finite width/height (case 6c) ---------------
2087
- check('A03_REGION_WIDTH_HEIGHT_FINITE', () => {
2088
- // ⟨subject⟩_⟨property⟩: with no region there is no size to find non-finite,
2089
- // and a loop over nothing used to report that as held (#580).
2090
- if (regionAttachments.length === 0) return skip('A03_REGION_WIDTH_HEIGHT_FINITE', SKIP_NO_REGION_ATTACHMENT);
2091
- for (const att of regionAttachments) {
2092
- if (!Number.isFinite(att.width) || !Number.isFinite(att.height)) {
2093
- fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" loaded w=${att.width} h=${att.height}`);
2094
- }
2095
- if (att.width <= 0 || att.height <= 0) {
2096
- fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" has a non-positive size`);
2097
- }
2098
- }
2099
- });
1965
+ check('A03_REGION_WIDTH_HEIGHT_FINITE', () => a03RegionWidthHeightFinite(verdicts, skinEntries));
2100
1966
 
2101
1967
  // --- A04: mesh triangles + encoding coherence (case 6f) ----------------
2102
1968
  check('A04_MESH_TRIANGLES_AND_ENCODING', () => {
@@ -2291,11 +2157,7 @@ export function validate(input: ValidateInput): ValidateReport {
2291
2157
  });
2292
2158
 
2293
2159
  // --- A11 / A13 / A14: renderer + canvas budgets ----
2294
- check('A11_NO_CLIPPING_ATTACHMENTS', () => {
2295
- if (clippingCount > 0) {
2296
- fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
2297
- }
2298
- });
2160
+ check('A11_NO_CLIPPING_ATTACHMENTS', () => a11NoClippingAttachments(verdicts, skinEntries));
2299
2161
  // 📐 The two numbers come from the rig spec's `invariants`, never from here.
2300
2162
  // A mesh budget is one consumer's frame time written down — the editor's own
2301
2163
  // example projects ship meshes many times denser and they are valid — so a
@@ -4796,174 +4658,10 @@ export function validate(input: ValidateInput): ValidateReport {
4796
4658
 
4797
4659
  // --- A45: the separable colour timelines own their channels ------------
4798
4660
  //
4799
- // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
4800
- // `alpha` pose no dark colour, so under A43's name a failure would be a
4801
- // verdict saying "two-colour tint" about a file that states one colour —
4802
- // the name would say something other than what it decides (the rule #712
4803
- // applied to A43 itself, against a clause on A10). And the SKIP is the
4804
- // sharper half: a rig with a `dark` and no separable timeline has A43's
4805
- // subject present, so a clause there could only PASS on it, which is a pass
4806
- // for an absent subject.
4807
- //
4808
- // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
4809
- // clauses are about the file against the runtime:
4810
- //
4811
- // 1. **One channel, one timeline.** Each colour timeline poses its
4812
- // channels at EVERY time — before its first key it writes the setup
4813
- // value — so when two of one slot share a channel, the one applied
4814
- // later (the one the file states later) overwrites the other
4815
- // everywhere and the first one's keys on it are read by nothing
4816
- // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
4817
- // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
4818
- // loads without a word.
4819
- // 2. **Posed as written.** Stepped to each key's own time, the channels
4820
- // the timeline writes are the key's: a colour that is not six hex
4821
- // digits loads as NaN, and a key whose time another key repeats is
4822
- // read by nothing, and both parse in silence.
4823
- //
4824
- // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
4825
- // setup alpha is NOT this rule's to see, and nothing that reads only the
4826
- // file can see it: the result is a correct `rgba`, which is also what an
4827
- // author keying the light colour and holding its alpha would write. What
4828
- // differs is what happens UNDER another track that moves the alpha, which
4829
- // is the consumer's composing rather than the object (CLAUDE.md). That
4830
- // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
4831
- //
4832
- // ⚠️ The required values are parsed HERE, as A43's are, and the channel
4833
- // table is `timelines.ts`'s rather than the loaded timelines' property ids:
4834
- // a check that asked the parser what a timeline writes would agree with it
4835
- // whatever it did. A selftest control holds that table to the runtime's ids.
4836
- check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => {
4837
- const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
4838
- /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
4839
- const readRgbHex = (hex: unknown): [number, number, number] | null => {
4840
- if (typeof hex !== 'string') return null;
4841
- const body = hex.startsWith('#') ? hex.slice(1) : hex;
4842
- if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
4843
- return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
4844
- };
4845
- /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
4846
- const HEX_STEP = 1 / 510;
4847
- const FLOAT_STEP = 1e-6;
4848
- const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
4849
- const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
4850
-
4851
- // -- the subjects, read off the FILE ---------------------------------
4852
- const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
4853
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
4854
- for (const [animName, anim] of Object.entries(rawAnimations)) {
4855
- if (!isObj(anim) || !isObj(anim.slots)) continue;
4856
- for (const [slotName, timelines] of Object.entries(anim.slots)) {
4857
- if (!isObj(timelines)) continue;
4858
- if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
4859
- subjects.push({ anim: animName, slot: slotName, timelines });
4860
- }
4861
- }
4862
- }
4863
- if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
4864
- stats.separableColorTimelines = subjects.reduce(
4865
- (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
4866
- 0,
4867
- );
4868
-
4869
- for (const { anim: animName, slot: slotName, timelines } of subjects) {
4870
- const at = `animation "${animName}" slot "${slotName}"`;
4871
- // In FILE order, which is the order `readAnimation` pushes them and so
4872
- // the order they apply in.
4873
- const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
4874
-
4875
- // -- clause 1: one channel, one timeline ---------------------------
4876
- let shared = false;
4877
- for (const channel of ['rgb', 'alpha', 'dark'] as const) {
4878
- const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
4879
- if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
4880
- shared = true;
4881
- const last = writers[writers.length - 1];
4882
- fail(
4883
- NAME,
4884
- `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
4885
- `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
4886
- `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
4887
- 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
4888
- );
4889
- }
4890
- if (shared) continue;
4891
-
4892
- // -- clause 2: posed as written ------------------------------------
4893
- //
4894
- // ⚠️ Two things this does NOT do, both measured rather than skipped:
4895
- // it does not read the loaded timeline's CLASS back, and it does not
4896
- // hold a channel the timeline leaves alone to the setup pose. Against
4897
- // the linked parser neither can fail — every `rgb` / `alpha` array that
4898
- // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
4899
- // three other outcomes (an empty array, a slot the skeleton lacks, a
4900
- // name outside the switch) throw at `A00` — so either would be a clause
4901
- // nobody can see fire. The selftest reads both off spine-core directly
4902
- // (`S82`–`S84`), where they are measurements of the runtime rather than
4903
- // checks on a file.
4904
- if (!data.findAnimation(animName)) {
4905
- fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
4906
- continue;
4907
- }
4908
- for (const name of ['rgb', 'alpha'] as const) {
4909
- const keys = timelines[name];
4910
- if (!Array.isArray(keys)) continue;
4911
- const where = `${at} ${name}`;
4912
- for (const rawKey of keys) {
4913
- if (!isObj(rawKey)) continue;
4914
- const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
4915
- const skeleton = new Skeleton(data);
4916
- const state = new AnimationState(new AnimationStateData(data));
4917
- state.setAnimation(0, animName, false);
4918
- skeleton.setupPose();
4919
- skeleton.update(0);
4920
- skeleton.updateWorldTransform(Physics.reset);
4921
- // At the key as the runtime stores it, not one float step before
4922
- // it — see `atStoredKey` (issue #771).
4923
- state.update(atStoredKey(time));
4924
- state.apply(skeleton);
4925
- const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
4926
- if (!posed) {
4927
- fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
4928
- continue;
4929
- }
4930
- const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
4931
- if (name === 'rgb') {
4932
- const stated = readRgbHex(rawKey.color);
4933
- if (stated === null) {
4934
- fail(
4935
- NAME,
4936
- `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
4937
- `digits — and the runtime poses (${show(light.slice(0, 3))})`,
4938
- );
4939
- } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
4940
- fail(
4941
- NAME,
4942
- `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
4943
- `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
4944
- );
4945
- }
4946
- } else {
4947
- // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
4948
- // omits it there, so absence is read the parser's way rather than
4949
- // as a malformed key.
4950
- const stated = rawKey.value === undefined ? 0 : rawKey.value;
4951
- if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
4952
- fail(
4953
- NAME,
4954
- `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
4955
- (typeof stated !== 'number'
4956
- ? ' — an alpha key\'s value is a number'
4957
- : stated < 0 || stated > 1
4958
- ? ' — the runtime clamps a posed alpha to 0..1'
4959
- : ''),
4960
- );
4961
- }
4962
- }
4963
- }
4964
- }
4965
- }
4966
- });
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)));
4967
4665
 
4968
4666
  // --- A44: a linked mesh states no geometry of its own ------------------
4969
4667
  //
@@ -5317,17 +5015,7 @@ export function validate(input: ValidateInput): ValidateReport {
5317
5015
  // green build had ever contained one; making that state legal is exactly what
5318
5016
  // #608 does, so the same state has to stop printing four passes about nothing.
5319
5017
  // `SKIP_NO_ATLAS_PAGE` is one string for all four because it is one condition.
5320
- check('A17_ATLAS_PAGE_FILES_EXIST', () => {
5321
- if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
5322
- // The other absence, one ring in (#580): the atlas LOADED and declares no
5323
- // page, so there is no file to find missing. `!atlas` and "zero pages" print
5324
- // different reasons because they are different facts about the artifact.
5325
- if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
5326
- for (const page of atlas.pages) {
5327
- const abs = resolve(input.atlasDir, page.name);
5328
- if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
5329
- }
5330
- });
5018
+ check('A17_ATLAS_PAGE_FILES_EXIST', () => a17AtlasPageFilesExist(verdicts, { atlas }, input));
5331
5019
  check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
5332
5020
  if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
5333
5021
  if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
@@ -6236,7 +5924,7 @@ export function validate(input: ValidateInput): ValidateReport {
6236
5924
  for (const name of ASSERTION_NAMES) {
6237
5925
  if (reported.has(name)) continue;
6238
5926
  const kind = ASSERTION_KIND[name];
6239
- if (kind !== 'validity' && !policy) profileSkipped.push({ assertion: name, kind });
5927
+ if (kind !== 'validity' && !kindRunsUnder(kind, profile)) profileSkipped.push({ assertion: name, kind });
6240
5928
  else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
6241
5929
  else {
6242
5930
  fail(