spine-rigc 0.2.1

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.
@@ -0,0 +1,1586 @@
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 { existsSync } from 'node:fs';
17
+ import { basename, dirname, resolve } from 'node:path';
18
+ import {
19
+ AnimationState,
20
+ AnimationStateData,
21
+ AtlasAttachmentLoader,
22
+ ClippingAttachment,
23
+ MeshAttachment,
24
+ Physics,
25
+ PhysicsConstraintData,
26
+ RegionAttachment,
27
+ Skeleton,
28
+ SkeletonJson,
29
+ TextureAtlas,
30
+ } from '@esotericsoftware/spine-core';
31
+ import { readPngInfo } from './png.ts';
32
+ import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
33
+ import type { RigInfo } from './types.ts';
34
+
35
+ export interface Failure {
36
+ assertion: string;
37
+ detail: string;
38
+ }
39
+
40
+ /**
41
+ * Which body of rules to hold the artifact to.
42
+ *
43
+ * ⭐ The distinction this draws is the difference between "wrong" and "not how we
44
+ * do it here", and conflating the two is how a validator stops being usable on
45
+ * anybody else's data. Nine of the 31 assertions are policy for one renderer
46
+ * (`spine-html`) or for one project's canvas budget, and every one of them fires
47
+ * on real, correct, editor-produced Spine data — the official example projects
48
+ * carry clipping attachments, unweighted meshes, 116-triangle meshes and packed
49
+ * atlases, all of which are perfectly valid and none of which spine-html likes.
50
+ *
51
+ * - `spine` — is this valid Spine 4.3 that any runtime will play correctly?
52
+ * - `spine-html` — the above, plus this project's renderer and archetype policy.
53
+ *
54
+ * ⚠️ `spine-html` is the DEFAULT and must stay the default: it is the behaviour
55
+ * every existing caller already has, and a switch that silently loosens a gate
56
+ * for callers who did not ask is worse than no switch at all.
57
+ */
58
+ export type ValidateProfile = 'spine' | 'spine-html';
59
+
60
+ export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
61
+
62
+ export const DEFAULT_PROFILE: ValidateProfile = 'spine-html';
63
+
64
+ /**
65
+ * What kind of rule each assertion is. Every assertion has an entry, and
66
+ * `check()` throws on a name that has none — a new assertion must state its kind
67
+ * rather than defaulting into one, because the default would decide, silently,
68
+ * whether it runs on foreign data.
69
+ *
70
+ * validity — the file is wrong for any consumer. Runs under every profile.
71
+ * renderer — valid Spine that this project's renderer or frame budget refuses.
72
+ * archetype — a structural rule about rigc's own formations, meaningless to a
73
+ * skeleton rigc did not compile.
74
+ *
75
+ * Three assertions are MIXED and are marked `validity` here because their
76
+ * validity half must never stop running; their policy clauses are gated inside
77
+ * the assertion body against `profile`, and each such clause says so where it
78
+ * lives. They are A06 (size-vs-PNG is validity; pma / rotation / full-page
79
+ * coverage are policy), A08 (the attachment→region join is validity; requiring
80
+ * the two names to be identical is policy) and A20 (weight coherence is
81
+ * validity; requiring a mesh to be weighted at all is policy).
82
+ */
83
+ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
84
+ A00_ROUNDTRIP_PARSE: 'validity',
85
+ A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
86
+ A02_NO_BONE_TRANSFORM_KEY: 'validity',
87
+ A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
88
+ A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
89
+ A05_CURVE_ARRAY_LENGTH: 'validity',
90
+ A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
91
+ A07_ATLAS_TEXT_SHAPE: 'validity',
92
+ A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // mixed — see above
93
+ A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
94
+ A10_NO_NAN_AFTER_STEPPING: 'validity',
95
+ A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
96
+ A12_NO_DARK_COLOR: 'renderer',
97
+ A13_MESH_BUDGET: 'renderer',
98
+ A14_NO_FULL_FRAME_MESH: 'renderer',
99
+ A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
100
+ A16_SKELETON_VERSION_4_3: 'validity',
101
+ A17_ATLAS_PAGE_FILES_EXIST: 'validity',
102
+ A18_DETERMINISTIC_EMIT: 'validity',
103
+ A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
104
+ A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
105
+ A21_MESH_RIM_PINNED: 'archetype',
106
+ A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
107
+ A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
108
+ A24_AXIS_SPACE_STROKE: 'archetype',
109
+ A25_DETACHED_BONE_PARENTAGE: 'archetype',
110
+ A26_SLOT_DRAW_ORDER: 'archetype',
111
+ A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
112
+ A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
113
+ A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
114
+ A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
115
+ A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
116
+ };
117
+
118
+ export interface ValidateInput {
119
+ skeletonText: string;
120
+ atlasText: string;
121
+ /** Directory the atlas lives in; page names resolve against it. */
122
+ atlasDir: string;
123
+ /** Declared durations from the motion spec. */
124
+ declaredDurations?: Record<string, number>;
125
+ /** Re-emitted artifacts, for the determinism check. */
126
+ reEmit?: { skeletonText: string; atlasText: string };
127
+ /**
128
+ * Structural expectations the artifact cannot state about itself: which mesh is
129
+ * a ribbon, which bone carries the axis, which parentage is forbidden, what the
130
+ * canonical draw order is. Absent when `validate <dir>` is pointed at a bare
131
+ * directory, and the assertions that need it then SKIP rather than guess — the
132
+ * stats line says `rig=absent` so a green run cannot be mistaken for a full one.
133
+ */
134
+ rig?: RigInfo;
135
+ /** Which body of rules to apply. Defaults to `spine-html`; see ValidateProfile. */
136
+ profile?: ValidateProfile;
137
+ }
138
+
139
+ export interface ValidateReport {
140
+ failures: Failure[];
141
+ /** Assertions that ran and passed, in order. */
142
+ passed: string[];
143
+ /**
144
+ * Assertions that had no data to run against, with the reason.
145
+ *
146
+ * ⚠️ Not cosmetic. An assertion whose subject is a per-cut MEASUREMENT (a
147
+ * contact depth, a containment ceiling) is vacuous on a cut that never measured
148
+ * one, and reporting that as PASS is this project's favourite false green: a
149
+ * gate that says it checked something it never looked at. So the report says
150
+ * SKIP and why, and `passed` does not count it.
151
+ */
152
+ skipped: Array<{ assertion: string; reason: string }>;
153
+ /** Which body of rules ran. */
154
+ profile: ValidateProfile;
155
+ /**
156
+ * Assertions this profile does not apply, with their kind.
157
+ *
158
+ * Kept separate from `skipped` on purpose: a SKIP means "there was nothing to
159
+ * look at", a profile skip means "this rule was deliberately out of scope".
160
+ * Reading a `--profile spine` green as though the renderer policy had passed
161
+ * is exactly the misreading the two lists exist to prevent.
162
+ */
163
+ profileSkipped: Array<{ assertion: string; kind: 'renderer' | 'archetype' }>;
164
+ stats: Record<string, number | string>;
165
+ }
166
+
167
+ const FRAME = 1 / 60;
168
+ const STEP_FRAMES = 120;
169
+
170
+ /**
171
+ * One step of the **float32** grid at `t`, which is the grid a loaded key time
172
+ * actually sits on: `spine-core` reads every timeline's frames into a
173
+ * `Float32Array`, so a time the compiler wrote as `32.366667` comes back as
174
+ * `32.366668701171875`.
175
+ *
176
+ * A09 needs this and the compiler does not, and that asymmetry is the reason it
177
+ * is a function rather than a constant. `KEY_TIME_EPSILON` is fixed because the
178
+ * compiler's own grid is fixed — `r6` puts every key time on 1e-6 s at any
179
+ * magnitude. A float32 step is not: 4.8e-7 s at 5 s, 3.8e-6 s at 32 s. Adding a
180
+ * flat epsilon to a comparison against a value that has been through float32
181
+ * would fail correct data for being long — 971 frames at 30 fps keyed exactly on
182
+ * its own declared duration arrives 2.0e-6 s late, twice the whole epsilon.
183
+ *
184
+ * The spacing of a normal float is 2^(exponent − 23), and `Math.log2` recovers
185
+ * the exponent. Zero takes the guard — a named empty animation declares
186
+ * `duration: 0` and A09 does compare it — and the magnitude is taken first, so a
187
+ * sign never reaches `log2`.
188
+ */
189
+ function float32Step(t: number): number {
190
+ const magnitude = Math.abs(t);
191
+ if (!Number.isFinite(magnitude) || magnitude === 0) return 0;
192
+ return 2 ** (Math.floor(Math.log2(magnitude)) - 23);
193
+ }
194
+
195
+ /**
196
+ * `4.3`, `4.3.<patch>`, or `4.3.<patch>-<suffix>` — the last of which is what the
197
+ * Spine editor writes for a pre-release (`"4.3.75-beta"` in all twelve official
198
+ * example exports). The major/minor pair is the load-bearing part; see A16.
199
+ */
200
+ const SPINE_4_3_VERSION = /^4\.3(\.\d+(-[0-9A-Za-z][0-9A-Za-z.+-]*)?)?$/;
201
+
202
+ type Json = Record<string, unknown>;
203
+
204
+ function isObj(v: unknown): v is Json {
205
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
206
+ }
207
+
208
+ export function validate(input: ValidateInput): ValidateReport {
209
+ const failures: Failure[] = [];
210
+ const passed: string[] = [];
211
+ const skipped: ValidateReport['skipped'] = [];
212
+ const profileSkipped: ValidateReport['profileSkipped'] = [];
213
+ const stats: Record<string, number | string> = {};
214
+ const profile = input.profile ?? DEFAULT_PROFILE;
215
+ /** True when this profile's rulebook includes the policy layer. */
216
+ const policy = profile === 'spine-html';
217
+
218
+ const fail = (assertion: string, detail: string) => failures.push({ assertion, detail });
219
+ /** Declare that an assertion had nothing to check, and why. */
220
+ const skip = (assertion: string, reason: string) => skipped.push({ assertion, reason });
221
+ /**
222
+ * Run one assertion; record it as passed only if it neither failed nor skipped,
223
+ * and do not run it at all when the profile does not carry that kind of rule.
224
+ *
225
+ * The unknown-assertion throw is deliberate: an assertion with no entry in
226
+ * ASSERTION_KIND would otherwise pick a profile by accident, and picking wrong
227
+ * means either a rule that never runs or a rule that fires on everybody's data.
228
+ *
229
+ * The body's return value is handed back — `undefined` when the assertion did
230
+ * not run or threw. Only A00 uses it, and it uses it so that the loaded atlas
231
+ * and skeleton can be `const`: assigning them from inside this callback leaves
232
+ * the type checker unable to see that they were ever set, and every later read
233
+ * of `atlas.pages` or `data.bones` becomes a property of `never`.
234
+ */
235
+ const check = <T>(assertion: string, body: () => T): T | undefined => {
236
+ const kind = ASSERTION_KIND[assertion];
237
+ if (!kind) throw new Error(`validate: assertion "${assertion}" has no ASSERTION_KIND entry`);
238
+ if (kind !== 'validity' && !policy) {
239
+ profileSkipped.push({ assertion, kind });
240
+ return undefined;
241
+ }
242
+ const before = failures.length;
243
+ const skippedBefore = skipped.length;
244
+ let result: T | undefined;
245
+ try {
246
+ result = body();
247
+ } catch (err) {
248
+ fail(assertion, `threw: ${(err as Error).message}`);
249
+ }
250
+ if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
251
+ return result;
252
+ };
253
+
254
+ // -------------------------------------------------------------------------
255
+ // Raw text / raw JSON assertions (they must run even if the parser is happy)
256
+ // -------------------------------------------------------------------------
257
+
258
+ let raw: Json | null = null;
259
+ try {
260
+ raw = JSON.parse(input.skeletonText) as Json;
261
+ } catch (err) {
262
+ fail('A00_ROUNDTRIP_PARSE', `skeleton JSON is not parseable: ${(err as Error).message}`);
263
+ }
264
+
265
+ // --- A07: atlas text shape ------------------------------------------------
266
+ // Two traps, both measured: a region name is the RAW
267
+ // line (only the page name is trimmed), and a blank line closes the page
268
+ // block, so a blank line between a page header and its regions turns the
269
+ // regions into pages.
270
+ const atlasLines = input.atlasText.replace(/\n$/, '').split('\n');
271
+ check('A07_ATLAS_TEXT_SHAPE', () => {
272
+ let expectPage = true;
273
+ let sawRegionForPage = false;
274
+ for (let i = 0; i < atlasLines.length; i++) {
275
+ const line = atlasLines[i];
276
+ if (line.trim().length === 0) {
277
+ if (expectPage) fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: consecutive blank lines`);
278
+ else if (!sawRegionForPage) {
279
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: blank line before this page had any region`);
280
+ }
281
+ expectPage = true;
282
+ sawRegionForPage = false;
283
+ continue;
284
+ }
285
+ if (expectPage) {
286
+ expectPage = false;
287
+ continue; // page name line
288
+ }
289
+ if (line.includes(':')) continue; // key: value line
290
+ // A bare non-key line is a region name, and it is used untrimmed.
291
+ if (line !== line.trim()) {
292
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: region name has stray whitespace: ${JSON.stringify(line)}`);
293
+ }
294
+ sawRegionForPage = true;
295
+ }
296
+ if (!sawRegionForPage && atlasLines.length) {
297
+ fail('A07_ATLAS_TEXT_SHAPE', 'the last page block declares no region');
298
+ }
299
+ });
300
+
301
+ // --- A31: every draw-order offset lands on a real place -------------------
302
+ //
303
+ // 🚨 This one runs BEFORE the round trip, and it is the only assertion that
304
+ // does so for a reason other than "the parser is happy about it". A draw-order
305
+ // key whose offsets are not in ascending slot order does not load wrong — it
306
+ // does not load at all. `readDrawOrder` (SkeletonJson.ts:1336-1374) walks a
307
+ // forward-only cursor:
308
+ //
309
+ // while (originalIndex !== index) unchanged[unchangedIndex++] = originalIndex++;
310
+ //
311
+ // and an entry naming an EARLIER slot than the one before it makes that
312
+ // condition unreachable, so the loader spins and grows an array until the
313
+ // process dies. So the check has to happen first, and when it finds that shape
314
+ // the round trip is not attempted at all — reported as such, not as a pass.
315
+ //
316
+ // The other two shapes are the format's usual silence. An offset that puts a
317
+ // slot outside the array writes past the end, leaves a −1 hole in the
318
+ // permutation, and the fill loop reads `unchanged[-1]` — `undefined` where a
319
+ // slot index belongs, with nothing thrown. Two entries for one slot write
320
+ // twice at one cursor position and the first move is simply lost.
321
+ let drawOrderIsUnparseable: string | null = null;
322
+ check('A31_DRAW_ORDER_OFFSETS_RESOLVE', () => {
323
+ if (!raw) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
324
+ if (!Array.isArray(raw.slots) || !isObj(raw.animations)) {
325
+ return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton declares no slots or no animations');
326
+ }
327
+ const slotIndex = new Map<string, number>();
328
+ (raw.slots as unknown[]).forEach((slot, i) => {
329
+ if (isObj(slot) && typeof slot.name === 'string') slotIndex.set(slot.name, i);
330
+ });
331
+ const slotCount = (raw.slots as unknown[]).length;
332
+ let sawATimeline = false;
333
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
334
+ if (!isObj(anim) || !Array.isArray(anim.drawOrder)) continue;
335
+ sawATimeline = true;
336
+ (anim.drawOrder as unknown[]).forEach((key, k) => {
337
+ const at = `animation "${animName}" drawOrder key ${k}`;
338
+ if (!isObj(key) || !Array.isArray(key.offsets)) return; // no offsets = setup order
339
+ let previous = -1;
340
+ for (const entry of key.offsets as unknown[]) {
341
+ if (!isObj(entry) || typeof entry.slot !== 'string' || typeof entry.offset !== 'number') {
342
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: an offset is not { slot: string, offset: number }`);
343
+ continue;
344
+ }
345
+ const index = slotIndex.get(entry.slot);
346
+ if (index === undefined) {
347
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: slot "${entry.slot}" is not in the skeleton`);
348
+ continue;
349
+ }
350
+ if (index <= previous) {
351
+ const detail =
352
+ `${at}: slot "${entry.slot}" is at index ${index}, after an entry at index ${previous} — ` +
353
+ 'offsets must be in ascending slot order or the loader never finishes reading them';
354
+ fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', detail);
355
+ drawOrderIsUnparseable ??= detail;
356
+ continue;
357
+ }
358
+ previous = index;
359
+ const landing = index + entry.offset;
360
+ if (!Number.isInteger(entry.offset) || landing < 0 || landing >= slotCount) {
361
+ fail(
362
+ 'A31_DRAW_ORDER_OFFSETS_RESOLVE',
363
+ `${at}: slot "${entry.slot}" is at index ${index} and offset ${entry.offset} puts it at ${landing}, ` +
364
+ `outside the ${slotCount} slots`,
365
+ );
366
+ }
367
+ }
368
+ });
369
+ }
370
+ if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
371
+ });
372
+
373
+ // --- A: the round trip ----------------------------------------------------
374
+ // The two loaded objects come back OUT of the assertion rather than being
375
+ // assigned into it. Everything below reads them, and a `let` written inside a
376
+ // callback is a value the type checker cannot see being set: it narrows to
377
+ // `null` at the first guard and to `never` inside it, so `atlas.pages` stops
378
+ // type-checking while working perfectly at runtime.
379
+ const roundTrip = check('A00_ROUNDTRIP_PARSE', () => {
380
+ if (drawOrderIsUnparseable !== null) {
381
+ throw new Error(`not attempted — the loader would not return: ${drawOrderIsUnparseable}`);
382
+ }
383
+ const parsedAtlas = new TextureAtlas(input.atlasText);
384
+ const json = new SkeletonJson(new AtlasAttachmentLoader(parsedAtlas));
385
+ return { atlas: parsedAtlas, data: json.readSkeletonData(JSON.parse(input.skeletonText)) };
386
+ });
387
+ const atlas: TextureAtlas | null = roundTrip?.atlas ?? null;
388
+ const skeletonData: ReturnType<SkeletonJson['readSkeletonData']> | null = roundTrip?.data ?? null;
389
+
390
+ if (atlas) {
391
+ stats.pages = atlas.pages.length;
392
+ stats.regions = atlas.regions.length;
393
+ }
394
+ if (skeletonData) {
395
+ stats.bones = skeletonData.bones.length;
396
+ stats.slots = skeletonData.slots.length;
397
+ stats.animations = skeletonData.animations.length;
398
+ stats.version = skeletonData.version ?? '(none)';
399
+ }
400
+
401
+ // --- A16: version label ---------------------------------------------------
402
+ //
403
+ // The label must be on the 4.3 line, and the line includes its pre-releases:
404
+ // every one of the nine official example exports declares "4.3.75-beta", which
405
+ // the original `/^4\.3(\.\d+)?$/` rejected. That made the first file of the
406
+ // benchmark ladder fail on a cosmetic string. What the assertion is actually
407
+ // for is the MAJOR.MINOR pair — a 4.2 or 5.x label is portable-fragile because
408
+ // some runtimes refuse a version mismatch outright (spine-runtimes CHANGELOG
409
+ // line 1678), while spine-ts stores the string and never compares it. So the
410
+ // patch component and any pre-release suffix after it are free, and 4.2/5.x
411
+ // stay rejected.
412
+ check('A16_SKELETON_VERSION_4_3', () => {
413
+ const declared = isObj(raw?.skeleton) ? (raw.skeleton as Json).spine : undefined;
414
+ if (typeof declared !== 'string' || !SPINE_4_3_VERSION.test(declared)) {
415
+ fail(
416
+ 'A16_SKELETON_VERSION_4_3',
417
+ `skeleton.spine is ${JSON.stringify(declared)}, expected 4.3, 4.3.<patch> or 4.3.<patch>-<suffix>`,
418
+ );
419
+ }
420
+ });
421
+
422
+ // --- A01: no legacy top-level constraint arrays ---------------------------
423
+ // 4.3 folds every constraint into one `constraints` array with a `type`.
424
+ // A 4.1/4.2-shaped `physics` array loads clean and the constraint just
425
+ // vanishes.
426
+ check('A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', () => {
427
+ for (const key of ['ik', 'transform', 'path', 'physics', 'slider']) {
428
+ if (raw && key in raw) {
429
+ fail(
430
+ 'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS',
431
+ `top-level "${key}" array present; 4.3 wants it inside "constraints" with type:"${key}"`,
432
+ );
433
+ }
434
+ }
435
+ });
436
+
437
+ // --- A02: no bone.transform key ------------------------------------------
438
+ // 4.3 renamed it to `inherit`; the old key loads and silently falls back to
439
+ // Normal inheritance (case 6b).
440
+ check('A02_NO_BONE_TRANSFORM_KEY', () => {
441
+ const bones = Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : [];
442
+ for (const bone of bones) {
443
+ if (isObj(bone) && 'transform' in bone) {
444
+ fail('A02_NO_BONE_TRANSFORM_KEY', `bone "${String(bone.name)}" uses 4.2's "transform"; 4.3 wants "inherit"`);
445
+ }
446
+ }
447
+ });
448
+
449
+ // --- A12: no dark / two-colour tint --------------------------------------
450
+ // Parsed, then silently ignored by spine-html.
451
+ check('A12_NO_DARK_COLOR', () => {
452
+ const slots = Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [];
453
+ for (const slot of slots) {
454
+ if (isObj(slot) && 'dark' in slot) {
455
+ fail('A12_NO_DARK_COLOR', `slot "${String(slot.name)}" declares a dark colour; the renderer ignores it`);
456
+ }
457
+ }
458
+ walkTimelines(raw, (path, kind, name) => {
459
+ if (kind === 'slot' && (name === 'rgba2' || name === 'rgb2')) {
460
+ fail('A12_NO_DARK_COLOR', `${path}: two-colour timeline "${name}" is silently ignored by the renderer`);
461
+ }
462
+ });
463
+ });
464
+
465
+ // --- A05: curve arrays are 4 numbers per value channel --------------------
466
+ check('A05_CURVE_ARRAY_LENGTH', () => {
467
+ walkTimelines(raw, (path, kind, name, keys) => {
468
+ const table = CHANNELS_BY_KIND[kind];
469
+ if (!(name in table)) {
470
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: unchecked ${kind} timeline "${name}" — extend the validator`);
471
+ return;
472
+ }
473
+ const channels = table[name];
474
+ for (const key of keys) {
475
+ if (!isObj(key) || !('curve' in key)) continue;
476
+ const curve = key.curve;
477
+ if (channels === null) {
478
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: timeline "${name}" cannot carry a curve`);
479
+ continue;
480
+ }
481
+ if (curve === 'stepped') continue;
482
+ if (!Array.isArray(curve)) {
483
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve is ${JSON.stringify(curve)}, expected "stepped" or an array`);
484
+ continue;
485
+ }
486
+ if (curve.length !== channels * 4) {
487
+ fail(
488
+ 'A05_CURVE_ARRAY_LENGTH',
489
+ `${path} (t=${String(key.time ?? 0)}): curve has ${curve.length} numbers, "${name}" needs ${channels} channels x 4 = ${channels * 4}`,
490
+ );
491
+ }
492
+ for (const n of curve) {
493
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
494
+ fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve holds a non-finite value ${JSON.stringify(n)}`);
495
+ }
496
+ }
497
+ }
498
+ });
499
+ });
500
+
501
+ // -------------------------------------------------------------------------
502
+ // Loaded-data assertions
503
+ // -------------------------------------------------------------------------
504
+
505
+ const regionAttachments: RegionAttachment[] = [];
506
+ const meshAttachments: MeshAttachment[] = [];
507
+ let clippingCount = 0;
508
+ const meshSlots = new Set<number>();
509
+
510
+ if (skeletonData) {
511
+ const data = skeletonData as NonNullable<typeof skeletonData>;
512
+ for (const skin of data.skins) {
513
+ for (const entry of skin.getAttachments()) {
514
+ const att = entry.attachment;
515
+ if (att instanceof RegionAttachment) regionAttachments.push(att);
516
+ else if (att instanceof MeshAttachment) {
517
+ meshAttachments.push(att);
518
+ meshSlots.add(entry.slotIndex);
519
+ } else if (att instanceof ClippingAttachment) clippingCount++;
520
+ }
521
+ }
522
+ stats.regionAttachments = regionAttachments.length;
523
+ stats.meshAttachments = meshAttachments.length;
524
+
525
+ // --- A03: every region has finite width/height (case 6c) ---------------
526
+ check('A03_REGION_WIDTH_HEIGHT_FINITE', () => {
527
+ for (const att of regionAttachments) {
528
+ if (!Number.isFinite(att.width) || !Number.isFinite(att.height)) {
529
+ fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" loaded w=${att.width} h=${att.height}`);
530
+ }
531
+ if (att.width <= 0 || att.height <= 0) {
532
+ fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" has a non-positive size`);
533
+ }
534
+ }
535
+ });
536
+
537
+ // --- A04: mesh triangles + encoding coherence (case 6f) ----------------
538
+ check('A04_MESH_TRIANGLES_AND_ENCODING', () => {
539
+ for (const mesh of meshAttachments) {
540
+ if (!mesh.triangles || mesh.triangles.length === 0) {
541
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" has no triangles`);
542
+ continue;
543
+ }
544
+ if (mesh.triangles.length % 3 !== 0) {
545
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" triangle count is not a multiple of 3`);
546
+ }
547
+ const vertexCount = mesh.worldVerticesLength / 2;
548
+ for (const idx of mesh.triangles) {
549
+ if (idx < 0 || idx >= vertexCount) {
550
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" index ${idx} is outside 0..${vertexCount - 1}`);
551
+ break;
552
+ }
553
+ }
554
+ // Weighted vs unweighted is decided by a length comparison alone — a
555
+ // coincidental match reads weight data as coordinates.
556
+ const weighted = !!mesh.bones;
557
+ if (weighted && mesh.vertices.length % 3 !== 0) {
558
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" weighted vertex run is not a multiple of 3`);
559
+ }
560
+ if (!weighted && mesh.vertices.length !== mesh.worldVerticesLength) {
561
+ fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" unweighted vertices disagree with uvs`);
562
+ }
563
+ }
564
+ });
565
+
566
+ // --- A11 / A13 / A14: renderer + canvas budgets ----
567
+ check('A11_NO_CLIPPING_ATTACHMENTS', () => {
568
+ if (clippingCount > 0) {
569
+ fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
570
+ }
571
+ });
572
+ // 📐 The two numbers come from the rig spec's `invariants`, never from here.
573
+ // A mesh budget is one consumer's frame time written down — the editor's own
574
+ // example projects ship meshes many times denser and they are valid — so a
575
+ // constant in the validator would fail correct foreign data in the name of
576
+ // somebody else's canvas. A rig that declares no budget has nothing to be
577
+ // measured against, and the assertion says so instead of inventing a wall.
578
+ check('A13_MESH_BUDGET', () => {
579
+ const slotBudget = input.rig?.meshSlotBudget ?? null;
580
+ const triangleBudget = input.rig?.meshTriangleBudget ?? null;
581
+ if (slotBudget === null && triangleBudget === null) {
582
+ return skip(
583
+ 'A13_MESH_BUDGET',
584
+ input.rig
585
+ ? `the rig "${input.rig.archetype}" declares no \`invariants.meshSlots\` or \`invariants.meshTriangles\` budget`
586
+ : 'no rig info (validating a bare directory), so no budget is declared',
587
+ );
588
+ }
589
+ if (slotBudget !== null && meshSlots.size > slotBudget) {
590
+ fail('A13_MESH_BUDGET', `${meshSlots.size} mesh slots, the rig budgets ${slotBudget}`);
591
+ }
592
+ if (triangleBudget === null) return;
593
+ for (const mesh of meshAttachments) {
594
+ const tris = (mesh.triangles?.length ?? 0) / 3;
595
+ if (tris > triangleBudget) {
596
+ fail('A13_MESH_BUDGET', `mesh "${mesh.name}" has ${tris} triangles, the rig budgets ${triangleBudget}`);
597
+ }
598
+ }
599
+ });
600
+ check('A14_NO_FULL_FRAME_MESH', () => {
601
+ const stageW = data.width || 0;
602
+ const stageH = data.height || 0;
603
+ for (const mesh of meshAttachments) {
604
+ if (stageW && stageH && mesh.width >= stageW && mesh.height >= stageH) {
605
+ fail('A14_NO_FULL_FRAME_MESH', `mesh "${mesh.name}" spans the whole ${stageW}x${stageH} stage`);
606
+ }
607
+ }
608
+ });
609
+
610
+ // --- A15: idle must not key a mesh-driving bone (dirty-skip lever) -----
611
+ check('A15_IDLE_NO_MESH_BONE_KEYS', () => {
612
+ const meshBoneNames = new Set<string>();
613
+ for (const slotIndex of meshSlots) meshBoneNames.add(data.slots[slotIndex].boneData.name);
614
+ // The slot's own bone is not the whole story once weights exist: a ring
615
+ // mesh is driven by its CONTROL bone, which is a different bone entirely.
616
+ // Checking only the slot bone would let `idle` key the one bone that
617
+ // actually dirties the canvas every frame.
618
+ for (const mesh of meshAttachments) {
619
+ if (!mesh.bones) continue;
620
+ for (let i = 0; i < mesh.bones.length; ) {
621
+ const boneCount = mesh.bones[i++];
622
+ for (let n = 0; n < boneCount; n++, i++) {
623
+ const bone = data.bones[mesh.bones[i]];
624
+ if (bone) meshBoneNames.add(bone.name);
625
+ }
626
+ }
627
+ }
628
+ const idle = isObj(raw?.animations) ? (raw.animations as Json).idle : undefined;
629
+ if (!isObj(idle) || !isObj(idle.bones)) return;
630
+ for (const boneName of Object.keys(idle.bones as Json)) {
631
+ if (meshBoneNames.has(boneName)) {
632
+ fail('A15_IDLE_NO_MESH_BONE_KEYS', `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip`);
633
+ }
634
+ }
635
+ });
636
+
637
+ // --- A20/A21/A22: the mesh checks the parser will never make ------------
638
+ //
639
+ // A mesh is the one attachment type where every mistake is silent. Bad
640
+ // weights do not throw, they skew; a uv outside the region samples the
641
+ // wrong pixels; and an unpinned rim moves the seam, which is the single
642
+ // thing the whole generated-parts approach depends on not happening.
643
+ const meshWeights = meshWeightsOf;
644
+
645
+ /** The slot a skin attachment belongs to; several assertions need it. */
646
+ const slotOfAttachment = (target: MeshAttachment): string | null => {
647
+ for (const skin of data.skins) {
648
+ for (const entry of skin.getAttachments()) {
649
+ if (entry.attachment === target) return data.slots[entry.slotIndex].name;
650
+ }
651
+ }
652
+ return null;
653
+ };
654
+ /**
655
+ * What built this mesh. ring unless the rig says otherwise; absent rig info
656
+ * reads as ring (legacy).
657
+ *
658
+ * 🚨 `authored` is not a third topology, it is the ABSENCE of one rigc may
659
+ * assume. Geometry that came in through the rig spec was drawn by somebody
660
+ * with an editor, and its rim, its row pairing and its entry edge are
661
+ * whatever that person made them. The `||` fallback below used to hand such
662
+ * a mesh the string `ring`, and A21 then checked ring topology on a shape
663
+ * that was never a ring — 40 failures on correct data (issue #44).
664
+ */
665
+ const kindOf = (target: MeshAttachment): 'ring' | 'ribbon' | 'authored' => {
666
+ const slot = slotOfAttachment(target);
667
+ return (slot && input.rig?.meshKinds[slot]) || 'ring';
668
+ };
669
+ /** Meshes rigc did not build, by name — the reason string several skips need. */
670
+ const authoredMeshNames = (list: MeshAttachment[]): string[] =>
671
+ list.filter((m) => kindOf(m) === 'authored').map((m) => `"${m.name}"`);
672
+
673
+ check('A20_MESH_WEIGHTS_COHERENT', () => {
674
+ for (const mesh of meshAttachments) {
675
+ // 🚨 Authored geometry is not rigc's to have opinions about. The two
676
+ // policy branches in this assertion are both statements about what a
677
+ // rigc GENERATOR is supposed to produce — "a mesh here is weighted",
678
+ // "a generated mesh binds only bones that move it" — and neither is a
679
+ // fact about Spine or about somebody else's mesh. Applying them to
680
+ // authored geometry failed correct data (issue #44). The coherence
681
+ // rules below the branch are unconditional and still apply.
682
+ const generated = kindOf(mesh) !== 'authored';
683
+ if (!mesh.bones) {
684
+ // 📐 PROFILE. An unweighted mesh is perfectly valid Spine — spineboy
685
+ // ships two — and the runtime poses it from the slot bone. What is
686
+ // NOT valid, in any profile, is a weighted mesh whose weights do not
687
+ // cohere, which is everything below this branch. So the requirement
688
+ // that a mesh be weighted at all is the policy half, and it is the
689
+ // only half gated here.
690
+ if (policy && generated) {
691
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" is unweighted; the ring tier drives meshes by bones`);
692
+ }
693
+ continue;
694
+ }
695
+ const perVertex = meshWeights(mesh);
696
+ const expected = mesh.worldVerticesLength / 2;
697
+ if (perVertex.length !== expected) {
698
+ fail(
699
+ 'A20_MESH_WEIGHTS_COHERENT',
700
+ `mesh "${mesh.name}" has weights for ${perVertex.length} vertices but ${expected} uv pairs`,
701
+ );
702
+ continue;
703
+ }
704
+ perVertex.forEach((vertex, i) => {
705
+ if (!vertex.length) fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has no bones`);
706
+ let sum = 0;
707
+ for (const { bone, weight } of vertex) {
708
+ if (!Number.isFinite(weight) || weight < 0) {
709
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has weight ${weight}`);
710
+ }
711
+ // 📐 PROFILE. A weight of exactly 0 is legal, harmless Spine: the
712
+ // runtime accumulates `(…) * weight` (Attachment.js:131), so the
713
+ // binding contributes nothing. The Spine editor writes them — the
714
+ // auto-weighted meshes in 6-arcs, 7-anticipation and 8-follow-through
715
+ // carry dozens, and their vertex weights still sum to 1. Treating one
716
+ // as corruption failed three rungs of the ladder on correct data.
717
+ // In a rigc-GENERATED ring or ribbon it is still a defect: the
718
+ // generator bound a bone that does nothing, which is a bug in the
719
+ // generator and dead work in the runtime's inner loop. So it stays a
720
+ // failure under spine-html and is not one under spine.
721
+ else if (policy && generated && weight === 0) {
722
+ fail(
723
+ 'A20_MESH_WEIGHTS_COHERENT',
724
+ `mesh "${mesh.name}" vertex ${i} is bound to bone index ${bone} at weight 0; a generated mesh binds only bones that move it`,
725
+ );
726
+ }
727
+ if (!(bone >= 0 && bone < data.bones.length)) {
728
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} references bone index ${bone}`);
729
+ }
730
+ sum += weight;
731
+ }
732
+ if (Math.abs(sum - 1) > 1e-3) {
733
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} weights sum to ${sum.toFixed(4)}`);
734
+ }
735
+ });
736
+ }
737
+ });
738
+
739
+ check('A21_MESH_RIM_PINNED', () => {
740
+ // Without the rig, ring and ribbon cannot be told apart — and the two kinds
741
+ // pin OPPOSITE edges, so guessing one would either check the wrong edge or
742
+ // check nothing while reporting a pass.
743
+ if (!input.rig) {
744
+ return skip('A21_MESH_RIM_PINNED', 'no rig info (validating a bare directory), so ring and ribbon cannot be told apart');
745
+ }
746
+ if (!meshAttachments.some((m) => m.bones)) {
747
+ return skip('A21_MESH_RIM_PINNED', 'the skeleton has no weighted mesh attachment, so there is no rim to find unpinned');
748
+ }
749
+ // An authored mesh has no rim rigc drew and no entry row rigc placed, so
750
+ // there is nothing here to measure against. Nothing to measure is a SKIP —
751
+ // never a pass, and never a failure on somebody else's correct geometry.
752
+ const measurable = meshAttachments.filter((m) => m.bones && kindOf(m) !== 'authored');
753
+ if (measurable.length === 0) {
754
+ const authored = authoredMeshNames(meshAttachments.filter((m) => m.bones));
755
+ return skip(
756
+ 'A21_MESH_RIM_PINNED',
757
+ `every weighted mesh here is authored geometry (${authored.join(', ')}), not a rigc ring or ribbon — ` +
758
+ 'rigc did not place its rim, so it has no rim of its own to find unpinned',
759
+ );
760
+ }
761
+ for (const mesh of measurable) {
762
+ if (!mesh.bones) continue;
763
+ const perVertexAll = meshWeights(mesh);
764
+ const slotBoneOf = (() => {
765
+ for (const skin of data.skins) {
766
+ for (const entry of skin.getAttachments()) {
767
+ if (entry.attachment === mesh) return data.slots[entry.slotIndex].boneData;
768
+ }
769
+ }
770
+ return null;
771
+ })();
772
+ // A ribbon's outer boundary is SUPPOSED to move — that is the whole point
773
+ // of a strip that changes length. So the rule splits by mesh kind rather
774
+ // than being relaxed: for a ribbon the invariant is that the ENTRY row
775
+ // cannot move, because that row is where the strip joins the part it
776
+ // comes out of. Both rules protect the same thing (the mesh's join to the
777
+ // plate underneath); they just live at different edges of the mesh.
778
+ if (kindOf(mesh) === 'ribbon') {
779
+ const uvs = mesh.regionUVs ?? [];
780
+ let entryRow = 0;
781
+ for (let v = 0; v < perVertexAll.length; v++) {
782
+ if (Math.abs(uvs[v * 2 + 1]) > 1e-6) continue; // not on the entry edge
783
+ entryRow++;
784
+ const vertex = perVertexAll[v];
785
+ if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
786
+ fail(
787
+ 'A21_MESH_RIM_PINNED',
788
+ `ribbon "${mesh.name}" entry vertex ${v} is not pinned (${vertex.map((w) => w.weight.toFixed(3)).join('+')})`,
789
+ );
790
+ continue;
791
+ }
792
+ if (slotBoneOf && data.bones[vertex[0].bone]?.name !== slotBoneOf.name) {
793
+ fail(
794
+ 'A21_MESH_RIM_PINNED',
795
+ `ribbon "${mesh.name}" entry vertex ${v} is pinned to "${data.bones[vertex[0].bone]?.name}", not the anchor bone "${slotBoneOf.name}"`,
796
+ );
797
+ }
798
+ }
799
+ if (entryRow < 2) {
800
+ fail('A21_MESH_RIM_PINNED', `ribbon "${mesh.name}" has ${entryRow} vertices on its entry edge; a strip needs two`);
801
+ }
802
+ continue;
803
+ }
804
+ const hullVertices = mesh.hullLength / 2;
805
+ if (!Number.isInteger(hullVertices) || hullVertices < 3) {
806
+ fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" declares hull ${mesh.hullLength / 2}; the rim must be a real ring`);
807
+ continue;
808
+ }
809
+ const perVertex = meshWeights(mesh);
810
+ if (hullVertices > perVertex.length) {
811
+ fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" hull is ${hullVertices} of ${perVertex.length} vertices`);
812
+ continue;
813
+ }
814
+ // The rim is the alpha contour where generated pixels meet untouched
815
+ // base. One bone at weight 1, and that bone must be the slot's own —
816
+ // anything else and the seam can move.
817
+ const slotBone = (() => {
818
+ for (const skin of data.skins) {
819
+ for (const entry of skin.getAttachments()) {
820
+ if (entry.attachment === mesh) return data.slots[entry.slotIndex].boneData;
821
+ }
822
+ }
823
+ return null;
824
+ })();
825
+ for (let i = 0; i < hullVertices; i++) {
826
+ const vertex = perVertex[i];
827
+ if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
828
+ fail(
829
+ 'A21_MESH_RIM_PINNED',
830
+ `mesh "${mesh.name}" rim vertex ${i} is not pinned (${vertex.map((v) => v.weight.toFixed(3)).join('+')})`,
831
+ );
832
+ continue;
833
+ }
834
+ if (slotBone && data.bones[vertex[0].bone]?.name !== slotBone.name) {
835
+ fail(
836
+ 'A21_MESH_RIM_PINNED',
837
+ `mesh "${mesh.name}" rim vertex ${i} is pinned to "${data.bones[vertex[0].bone]?.name}", not the slot bone "${slotBone.name}"`,
838
+ );
839
+ }
840
+ }
841
+ // Independent of the ring ORDER: whatever sits on the region border is
842
+ // the outline, and the outline moving means the part's own edge moving.
843
+ // Without this, reordering the rings would move the pinned prefix off
844
+ // the outline and A21 would still pass on the count alone.
845
+ const uvs = mesh.regionUVs ?? [];
846
+ for (let v = 0; v < perVertex.length; v++) {
847
+ const u = uvs[v * 2];
848
+ const t = uvs[v * 2 + 1];
849
+ const onBorder = [u, t].some((c) => Math.abs(c) < 1e-6 || Math.abs(c - 1) < 1e-6);
850
+ if (!onBorder) continue;
851
+ const vertex = perVertex[v];
852
+ if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
853
+ fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" vertex ${v} is on the region border but not pinned`);
854
+ break;
855
+ }
856
+ }
857
+ }
858
+ });
859
+
860
+ check('A22_MESH_UVS_IN_UNIT_RANGE', () => {
861
+ for (const mesh of meshAttachments) {
862
+ // `regionUVs` is what the JSON authored; `uvs` is the page-space result
863
+ // and stays EMPTY until a renderer calls computeUVs, so asserting on it
864
+ // here would be asserting on the wrong array (measured: length 0 after
865
+ // a clean load). With one part per page the two are equal anyway —
866
+ // computeUVs reduces to `u + regionUV * width` with u=0, width=1
867
+ // (MeshAttachment.js:173-174), which is the claim this assertion rests
868
+ // on and this is where it is checked.
869
+ const uvs = mesh.regionUVs;
870
+ if (!uvs || uvs.length !== mesh.worldVerticesLength) {
871
+ fail(
872
+ 'A22_MESH_UVS_IN_UNIT_RANGE',
873
+ `mesh "${mesh.name}" has ${uvs?.length ?? 0} authored uv values for ${mesh.worldVerticesLength}`,
874
+ );
875
+ continue;
876
+ }
877
+ for (let i = 0; i < uvs.length; i++) {
878
+ if (!Number.isFinite(uvs[i]) || uvs[i] < -1e-6 || uvs[i] > 1 + 1e-6) {
879
+ fail('A22_MESH_UVS_IN_UNIT_RANGE', `mesh "${mesh.name}" uv[${i}] is ${uvs[i]}`);
880
+ break;
881
+ }
882
+ }
883
+ }
884
+ });
885
+
886
+ // --- A23: a physics constraint that does nothing, quietly ---------------
887
+ //
888
+ // Every failure mode here is silent. The five component fields default to
889
+ // 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
890
+ // becomes an infinite massInverse; and `damping` >= 1 never settles, which
891
+ // on a mesh-driving bone means the canvas re-rasterises forever.
892
+ check('A23_PHYSICS_CONSTRAINT_EFFECTIVE', () => {
893
+ const meshBoneNames = new Set<string>();
894
+ for (const slotIndex of meshSlots) meshBoneNames.add(data.slots[slotIndex].boneData.name);
895
+ for (const mesh of meshAttachments) {
896
+ if (!mesh.bones) continue;
897
+ for (let i = 0; i < mesh.bones.length; ) {
898
+ const boneCount = mesh.bones[i++];
899
+ for (let n = 0; n < boneCount; n++, i++) {
900
+ const bone = data.bones[mesh.bones[i]];
901
+ if (bone) meshBoneNames.add(bone.name);
902
+ }
903
+ }
904
+ }
905
+ for (const constraint of data.constraints) {
906
+ if (!(constraint instanceof PhysicsConstraintData)) continue;
907
+ const where = `physics "${constraint.name}"`;
908
+ const components = (['x', 'y', 'rotate', 'scaleX', 'shearX'] as const).filter((k) => constraint[k] > 0);
909
+ if (!components.length) {
910
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} drives no component; it parses and does nothing`);
911
+ }
912
+ const pose = constraint.setupPose;
913
+ if (!(pose.mix > 0)) fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has mix ${pose.mix}; it is muted`);
914
+ if (!Number.isFinite(pose.massInverse) || pose.massInverse <= 0) {
915
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has massInverse ${pose.massInverse} (mass must be > 0)`);
916
+ }
917
+ if (!(pose.strength > 0)) {
918
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has strength ${pose.strength}; nothing pulls it back`);
919
+ }
920
+ if (!(pose.damping > 0 && pose.damping < 1)) {
921
+ fail(
922
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
923
+ `${where} has damping ${pose.damping}; outside (0,1) it never settles` +
924
+ (meshBoneNames.has(constraint.bone.name) ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
925
+ );
926
+ }
927
+ if (constraint.step <= 0 || !Number.isFinite(constraint.step)) {
928
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${constraint.step} (fps must be > 0)`);
929
+ }
930
+ }
931
+ stats.physicsConstraints = data.constraints.filter((c) => c instanceof PhysicsConstraintData).length;
932
+ });
933
+
934
+ // --- A08: region names exact-match attachment names --------------------
935
+ check('A08_REGION_NAMES_MATCH_ATTACHMENTS', () => {
936
+ const regionNames = new Set(atlas!.regions.map((r) => r.name));
937
+ for (const skin of data.skins) {
938
+ for (const entry of skin.getAttachments()) {
939
+ const att = entry.attachment;
940
+ const lookup = att instanceof RegionAttachment || att instanceof MeshAttachment ? att.path || att.name : null;
941
+ if (lookup === null) continue;
942
+ if (lookup !== lookup.trim()) {
943
+ fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment path ${JSON.stringify(lookup)} has stray whitespace`);
944
+ }
945
+ if (!regionNames.has(lookup)) {
946
+ fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" wants region "${lookup}", which the atlas does not have`);
947
+ }
948
+ // 📐 PROFILE. That the join RESOLVES is validity — an attachment
949
+ // pointing at a region the atlas does not have is a hole in the rig
950
+ // whoever loads it. That the two names are IDENTICAL is rigc's v0
951
+ // policy: it holds because there is no packer, and a real packer
952
+ // renames regions by design (spineboy's `path` differs from its
953
+ // placeholder in 26 attachments).
954
+ if (policy && entry.placeholder !== lookup) {
955
+ fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" resolves to region "${lookup}"; v0 requires them identical`);
956
+ }
957
+ }
958
+ }
959
+ for (const region of atlas!.regions) {
960
+ if (region.name !== region.name.trim()) {
961
+ fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `atlas region ${JSON.stringify(region.name)} has stray whitespace`);
962
+ }
963
+ }
964
+ });
965
+
966
+ // --- A09: compiled duration == declared duration (rule 4) --------------
967
+ check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => {
968
+ // Without the spec there is no declared duration to compare the compiled
969
+ // one against, and returning here used to count as a PASS — a gate saying
970
+ // it checked something it never looked at.
971
+ if (!input.declaredDurations) {
972
+ return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', 'no motion spec supplied, so no declared duration to compare against');
973
+ }
974
+ // Same trap one level down. A **static rig** — a skeleton that exists to
975
+ // be posed and carries no animation at all, which is what
976
+ // `1-weight-and-mass`'s second export is — declares nothing and loads
977
+ // nothing, so both loops below iterate zero times and the assertion
978
+ // reported PASS. That is the vacuous green this report is built to refuse:
979
+ // there is no duration here, and saying so is the honest answer.
980
+ if (Object.keys(input.declaredDurations).length === 0 && data.animations.length === 0) {
981
+ return skip(
982
+ 'A09_ANIMATION_DURATION_MATCHES_SPEC',
983
+ 'the motion spec declares no animations and the skeleton has none — a static rig has no duration to compare',
984
+ );
985
+ }
986
+ for (const [name, declared] of Object.entries(input.declaredDurations)) {
987
+ const anim = data.findAnimation(name);
988
+ if (!anim) {
989
+ fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `spec declares animation "${name}" but the skeleton has none`);
990
+ continue;
991
+ }
992
+ // Two arms, and the asymmetry is the point.
993
+ //
994
+ // UNDERSHOOT is R7's question — is the declared duration wrong? An
995
+ // animation may hold its final pose, so a last key a little before the
996
+ // end is ordinary and a frame of that is slack.
997
+ //
998
+ // OVERSHOOT is a different question with a different tolerance.
999
+ // `anim.duration` IS the largest key time (`SkeletonJson.ts:1261` takes
1000
+ // the max over every timeline's own duration), so a loaded duration past
1001
+ // the declared one means a KEY is past it — and nothing that plays the
1002
+ // animation for the duration it declares will ever reach that key. Rung
1003
+ // 6 lost a one-frame attachment reveal to a key 3.4e-5 s past the end,
1004
+ // 1/500 of FRAME, which this comparison read as agreement (issue #54).
1005
+ // `compile.ts` refuses that per timeline now; this is the same rule held
1006
+ // against a skeleton the compiler never saw.
1007
+ const slack = KEY_TIME_EPSILON + float32Step(declared);
1008
+ const past = anim.duration - declared;
1009
+ if (past > slack) {
1010
+ const late = anim.timelines.filter((t) => t.getDuration() - declared > slack).length;
1011
+ fail(
1012
+ 'A09_ANIMATION_DURATION_MATCHES_SPEC',
1013
+ `animation "${name}" has ${late} timeline(s) keyed past the declared duration ${declared}s — ` +
1014
+ `the last key is at ${anim.duration}s, ${past.toFixed(6)}s late, so nothing ever samples it`,
1015
+ );
1016
+ } else if (declared - anim.duration > FRAME) {
1017
+ fail(
1018
+ 'A09_ANIMATION_DURATION_MATCHES_SPEC',
1019
+ `animation "${name}" loaded duration ${anim.duration}s, spec declares ${declared}s`,
1020
+ );
1021
+ }
1022
+ }
1023
+ for (const anim of data.animations) {
1024
+ if (!(anim.name in input.declaredDurations)) {
1025
+ fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `skeleton has animation "${anim.name}" with no spec entry`);
1026
+ }
1027
+ }
1028
+ });
1029
+
1030
+ // --- A10: step every animation and look for NaN ------------------------
1031
+ check('A10_NO_NAN_AFTER_STEPPING', () => {
1032
+ for (const anim of data.animations) {
1033
+ const skeleton = new Skeleton(data);
1034
+ const state = new AnimationState(new AnimationStateData(data));
1035
+ state.setAnimation(0, anim.name, true);
1036
+ skeleton.setupPose();
1037
+ skeleton.update(0);
1038
+ skeleton.updateWorldTransform(Physics.reset);
1039
+ const step = Math.max(anim.duration, 1) / STEP_FRAMES;
1040
+ for (let i = 0; i < STEP_FRAMES; i++) {
1041
+ state.update(step);
1042
+ state.apply(skeleton);
1043
+ skeleton.update(step);
1044
+ skeleton.updateWorldTransform(Physics.update);
1045
+ for (const bone of skeleton.bones) {
1046
+ const pose = bone.appliedPose;
1047
+ if (!Number.isFinite(pose.worldX) || !Number.isFinite(pose.worldY)) {
1048
+ fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: bone "${bone.data.name}" world is (${pose.worldX}, ${pose.worldY})`);
1049
+ return;
1050
+ }
1051
+ }
1052
+ for (const slot of skeleton.slots) {
1053
+ const c = slot.appliedPose.color;
1054
+ if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) {
1055
+ fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: slot "${slot.data.name}" colour is non-finite`);
1056
+ return;
1057
+ }
1058
+ }
1059
+ }
1060
+ }
1061
+ });
1062
+ }
1063
+
1064
+ // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
1065
+ // Case 6h: a `size:` that disagrees with the file loads fine and collapses
1066
+ // every UV — rigid stays correct, meshes sample a corner scrap.
1067
+ check('A17_ATLAS_PAGE_FILES_EXIST', () => {
1068
+ if (!atlas) return;
1069
+ for (const page of atlas.pages) {
1070
+ const abs = resolve(input.atlasDir, page.name);
1071
+ if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
1072
+ }
1073
+ });
1074
+ check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
1075
+ if (!atlas) return;
1076
+ for (const page of atlas.pages) {
1077
+ const abs = resolve(input.atlasDir, page.name);
1078
+ if (!existsSync(abs)) continue; // A17 owns this
1079
+ const info = readPngInfo(abs);
1080
+ if (page.width !== info.width || page.height !== info.height) {
1081
+ fail(
1082
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
1083
+ `page "${page.name}" declares ${page.width}x${page.height} but the PNG is ${info.width}x${info.height}`,
1084
+ );
1085
+ }
1086
+ // 📐 PROFILE, from here down. `pma: false`, one region per page and no
1087
+ // rotation are rigc's atlas CONVENTION, not the atlas format's rules — a
1088
+ // packed page with `rotate: 90` is what the Spine packer produces and
1089
+ // every official example ships one. The convention is what makes the
1090
+ // attachment -> region -> file chain checkable exactly (A27), so it stays
1091
+ // on for spine-html; under `spine` an atlas is judged only on whether its
1092
+ // declared size matches the file it names.
1093
+ if (policy && page.pma) {
1094
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `page "${page.name}" claims premultiplied alpha; parts are straight alpha`);
1095
+ }
1096
+ }
1097
+ if (!policy) return;
1098
+ for (const region of atlas.regions) {
1099
+ if (region.u !== 0 || region.v !== 0 || region.u2 !== 1 || region.v2 !== 1) {
1100
+ fail(
1101
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
1102
+ `region "${region.name}" has UVs (${region.u},${region.v})-(${region.u2},${region.v2}); one part per page must cover the page exactly`,
1103
+ );
1104
+ }
1105
+ if (region.degrees !== 0) {
1106
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `region "${region.name}" is rotated; there is no packer, so nothing can be`);
1107
+ }
1108
+ }
1109
+ });
1110
+ // An overlay part must carry an alpha channel or it cannot be an overlay: it
1111
+ // would paint an opaque rectangle over the untouched base, and an overlay
1112
+ // formation's whole claim is that the still frame has no seam. The base
1113
+ // plate itself is the one page allowed to be opaque, and it identifies
1114
+ // itself structurally — it is the region that covers the whole stage.
1115
+ check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => {
1116
+ if (!atlas) return;
1117
+ const stageW = skeletonData?.width ?? 0;
1118
+ const stageH = skeletonData?.height ?? 0;
1119
+ const basePages = new Set<string>();
1120
+ for (const att of regionAttachments) {
1121
+ if (stageW && stageH && att.width >= stageW && att.height >= stageH) {
1122
+ const region = atlas.findRegion(att.path || att.name);
1123
+ if (region) basePages.add(region.page.name);
1124
+ }
1125
+ }
1126
+ for (const page of atlas.pages) {
1127
+ const abs = resolve(input.atlasDir, page.name);
1128
+ if (!existsSync(abs)) continue;
1129
+ const info = readPngInfo(abs);
1130
+ if (info.hasAlpha) continue;
1131
+ if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
1132
+ fail(
1133
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
1134
+ `overlay page "${page.name}" has colour type ${info.colourType}, which carries no alpha channel`,
1135
+ );
1136
+ }
1137
+ });
1138
+
1139
+ // -------------------------------------------------------------------------
1140
+ // Archetype assertions — the invariants the RIG declares about itself.
1141
+ //
1142
+ // These need `input.rig`, because skeleton JSON does not record which bone
1143
+ // carries the axis, which parentage is forbidden, what the canonical draw
1144
+ // order is, or which mesh is a ribbon. Every one of them reads the rig spec's
1145
+ // `invariants` block ([`src/rig.ts`](rig.ts)) and every one of them SKIPs when
1146
+ // the field it needs is absent — a rig that declares nothing is not thereby
1147
+ // certified, it is unmeasured, and the two must never print the same.
1148
+ // -------------------------------------------------------------------------
1149
+ stats.rig = input.rig ? input.rig.archetype : 'absent';
1150
+ stats.profile = profile;
1151
+
1152
+ // --- A24: motion under the axis bone stays in AXIS space -----------------
1153
+ //
1154
+ // ⭐ The keystone of an articulated cut, and it exists because of a real bug:
1155
+ // a generator wrote its travel as screen-space x/y pairs (`x: 30, y: 8` ->
1156
+ // `x: -45, y: -12`), so every key had to be re-recorded for a cut framed at a
1157
+ // different camera angle — and sibling variants of ONE cut can differ by tens
1158
+ // of degrees. Put the direction in the axis bone's setup rotation instead and
1159
+ // the keys become translateX along it, reusable across every variant. A
1160
+ // screen-space y component on any bone in that subtree therefore means
1161
+ // somebody has put the direction back into the keys.
1162
+ //
1163
+ // The axis bone itself must carry no keys at all: `invariants.axisBone` names
1164
+ // a per-cut SETUP value, and animating it swings the whole formation.
1165
+ check('A24_AXIS_SPACE_STROKE', () => {
1166
+ const rig = input.rig;
1167
+ if (!rig) return skip('A24_AXIS_SPACE_STROKE', 'no rig info (validating a bare directory)');
1168
+ if (!rig.axisBone) {
1169
+ return skip(
1170
+ 'A24_AXIS_SPACE_STROKE',
1171
+ `the rig "${rig.archetype}" declares no axis bone, so there is no axis space for a stroke to leave`,
1172
+ );
1173
+ }
1174
+ const subtree = new Set(rig.axisSubtree);
1175
+ const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
1176
+ for (const [animName, anim] of Object.entries(anims)) {
1177
+ if (!isObj(anim) || !isObj(anim.bones)) continue;
1178
+ for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
1179
+ if (boneName === rig.axisBone) {
1180
+ fail(
1181
+ 'A24_AXIS_SPACE_STROKE',
1182
+ `"${animName}" keys the axis bone "${boneName}"; the axis angle is a per-cut SETUP value, not animation`,
1183
+ );
1184
+ continue;
1185
+ }
1186
+ if (!subtree.has(boneName) || !isObj(timelines)) continue;
1187
+ if ('translatey' in timelines) {
1188
+ fail(
1189
+ 'A24_AXIS_SPACE_STROKE',
1190
+ `"${animName}" gives "${boneName}" a translatey timeline; a bone under "${rig.axisBone}" moves along the axis only`,
1191
+ );
1192
+ }
1193
+ const keys = (timelines as Json).translate;
1194
+ if (!Array.isArray(keys)) continue;
1195
+ for (const key of keys) {
1196
+ if (!isObj(key)) continue;
1197
+ const y = Number(key.y ?? 0);
1198
+ if (Number.isFinite(y) && Math.abs(y) > 1e-6) {
1199
+ fail(
1200
+ 'A24_AXIS_SPACE_STROKE',
1201
+ `"${animName}" keys "${boneName}" translate y=${y} at t=${String(key.time ?? 0)}; the axis bone carries the direction, so keys are translateX only`,
1202
+ );
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+ });
1208
+
1209
+ // --- A25: parentage that must never happen -------------------------------
1210
+ //
1211
+ // ⚠️ Some bones are detached ON PURPOSE. An emitter that releases something
1212
+ // into the world must not ride the part that released it, or what it emits
1213
+ // gets dragged along with every stroke instead of staying where it left and
1214
+ // taking gravity. The rig states each such pair in `invariants.detached`, with
1215
+ // the reason it is tempting, because the wrong parentage still loads and still
1216
+ // animates — it just lies. That is exactly the class of invariant that belongs
1217
+ // in a machine guard rather than in prose.
1218
+ check('A25_DETACHED_BONE_PARENTAGE', () => {
1219
+ const rig = input.rig;
1220
+ if (!rig) return skip('A25_DETACHED_BONE_PARENTAGE', 'no rig info (validating a bare directory)');
1221
+ if (!rig.detached.length) {
1222
+ return skip('A25_DETACHED_BONE_PARENTAGE', `the rig "${rig.archetype}" declares no forbidden parentage`);
1223
+ }
1224
+ const parentOf = new Map<string, string | null>();
1225
+ for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
1226
+ if (isObj(bone) && typeof bone.name === 'string') {
1227
+ parentOf.set(bone.name, typeof bone.parent === 'string' ? bone.parent : null);
1228
+ }
1229
+ }
1230
+ for (const [child, forbidden] of rig.detached) {
1231
+ if (!parentOf.has(child)) {
1232
+ fail('A25_DETACHED_BONE_PARENTAGE', `the rig declares "${child}" detached from "${forbidden}" but has no such bone`);
1233
+ continue;
1234
+ }
1235
+ const seen = new Set<string>();
1236
+ for (let cursor = parentOf.get(child) ?? null; cursor; cursor = parentOf.get(cursor) ?? null) {
1237
+ if (seen.has(cursor)) break; // a cycle; the loader would have thrown first
1238
+ seen.add(cursor);
1239
+ if (cursor !== forbidden) continue;
1240
+ fail(
1241
+ 'A25_DETACHED_BONE_PARENTAGE',
1242
+ `"${child}" is a descendant of "${forbidden}"; it must not be dragged by that bone's motion`,
1243
+ );
1244
+ break;
1245
+ }
1246
+ }
1247
+ });
1248
+
1249
+ // --- A26: draw order matches the rig's slot table ------------------------
1250
+ //
1251
+ // The slots array IS the draw order (z-index = array index), so a formation
1252
+ // whose illusion depends on one part occluding another depends on one
1253
+ // adjacency in that array — and nothing in the file objects to the wrong
1254
+ // order. On a still frame it can even look plausible. The rig's own slot list
1255
+ // is the canonical table; a cut that fills only some of those slots emits a
1256
+ // SUBSEQUENCE of it, which is what this checks.
1257
+ check('A26_SLOT_DRAW_ORDER', () => {
1258
+ if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
1259
+ const order = input.rig.slotOrder;
1260
+ if (!order) {
1261
+ return skip(
1262
+ 'A26_SLOT_DRAW_ORDER',
1263
+ `the rig "${input.rig.archetype}" declares no canonical slot order, so the emitted order has nothing to disagree with`,
1264
+ );
1265
+ }
1266
+ const names = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [])
1267
+ .filter(isObj)
1268
+ .map((s) => String(s.name));
1269
+ let at = 0;
1270
+ for (const name of names) {
1271
+ const found = order.indexOf(name, at);
1272
+ if (found < 0) {
1273
+ const known = order.indexOf(name);
1274
+ fail(
1275
+ 'A26_SLOT_DRAW_ORDER',
1276
+ known < 0
1277
+ ? `slot "${name}" is not in the archetype's slot table`
1278
+ : `slot "${name}" is drawn out of order (table position ${known}, after a slot at ${at})`,
1279
+ );
1280
+ return;
1281
+ }
1282
+ at = found + 1;
1283
+ }
1284
+ });
1285
+
1286
+ // --- A27: region name == the PNG's basename ------------------------------
1287
+ //
1288
+ // The join key is a chain of three names — attachment -> atlas region -> file —
1289
+ // and A08 only holds the first link. The second was held by convention alone:
1290
+ // an atlas could declare page `../plates/02_overlay.png` with a region
1291
+ // called anything at all, every attachment could agree with it, and the rig
1292
+ // would load with the wrong pixels under the right name. One part per page (A06
1293
+ // forces it) makes the check exact.
1294
+ check('A27_REGION_NAME_MATCHES_PAGE_FILENAME', () => {
1295
+ if (!atlas) return;
1296
+ const perPage = new Map<string, number>();
1297
+ for (const region of atlas.regions) perPage.set(region.page.name, (perPage.get(region.page.name) ?? 0) + 1);
1298
+ for (const region of atlas.regions) {
1299
+ // A real packer puts many regions on one page and the names stop matching
1300
+ // filenames by design. Then this check has nothing to say, so it says
1301
+ // nothing rather than something wrong.
1302
+ if ((perPage.get(region.page.name) ?? 0) !== 1) continue;
1303
+ const expected = basename(region.page.name).replace(/\.png$/i, '');
1304
+ if (region.name !== expected) {
1305
+ fail(
1306
+ 'A27_REGION_NAME_MATCHES_PAGE_FILENAME',
1307
+ `region "${region.name}" is the only region on page "${region.page.name}", whose basename is "${expected}"`,
1308
+ );
1309
+ }
1310
+ }
1311
+ });
1312
+
1313
+ // --- A28: a ribbon's rows share their weights ----------------------------
1314
+ //
1315
+ // This is what makes "length without width" a property of the file rather than
1316
+ // a hope. Both vertices of a row carry the same bones at the same weights, so
1317
+ // whatever the chain does to one it does to the other and their separation can
1318
+ // only rotate — the strip curves and stretches, and never gets fatter. Give one
1319
+ // side a different weight and the strip develops a taper that grows with its
1320
+ // travel, which is the sort of thing that reads as bad art rather than as a bug.
1321
+ check('A28_RIBBON_ROWS_SHARE_WEIGHTS', () => {
1322
+ if (!skeletonData) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'the skeleton did not load (A00 owns that failure)');
1323
+ if (!input.rig) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'no rig info (validating a bare directory), so no mesh is known to be a ribbon');
1324
+ const kinds = input.rig.meshKinds;
1325
+ if (!Object.values(kinds).includes('ribbon')) {
1326
+ // Say WHICH kind of "no ribbon" this is. "declares no ribbon" is true of a
1327
+ // cut with no mesh at all and of one whose meshes are authored geometry,
1328
+ // and only the second is a case where a reader should know that a mesh
1329
+ // went unmeasured on purpose.
1330
+ const authored = Object.entries(kinds)
1331
+ .filter(([, kind]) => kind === 'authored')
1332
+ .map(([slot]) => `"${slot}"`);
1333
+ return skip(
1334
+ 'A28_RIBBON_ROWS_SHARE_WEIGHTS',
1335
+ authored.length
1336
+ ? `the rig "${input.rig.archetype}" declares no ribbon mesh on this cut — its mesh slot(s) ${authored.join(', ')} carry authored geometry, whose rows rigc did not pair`
1337
+ : `the rig "${input.rig.archetype}" declares no ribbon mesh on this cut`,
1338
+ );
1339
+ }
1340
+ const data = skeletonData as NonNullable<typeof skeletonData>;
1341
+ for (const skin of data.skins) {
1342
+ for (const entry of skin.getAttachments()) {
1343
+ const mesh = entry.attachment;
1344
+ if (!(mesh instanceof MeshAttachment) || !mesh.bones) continue;
1345
+ if (input.rig.meshKinds[data.slots[entry.slotIndex].name] !== 'ribbon') continue;
1346
+ const perVertex = meshWeightsOf(mesh);
1347
+ if (perVertex.length % 2 !== 0) {
1348
+ fail('A28_RIBBON_ROWS_SHARE_WEIGHTS', `ribbon "${mesh.name}" has ${perVertex.length} vertices; a strip has an even count`);
1349
+ continue;
1350
+ }
1351
+ // Perimeter order: left row i is index i, right row i is its mirror.
1352
+ const rows = perVertex.length / 2;
1353
+ for (let i = 0; i < rows; i++) {
1354
+ const left = perVertex[i];
1355
+ const right = perVertex[perVertex.length - 1 - i];
1356
+ const shape = (v: typeof left) => v.map((w) => `${w.bone}:${w.weight.toFixed(6)}`).join(',');
1357
+ if (shape(left) !== shape(right)) {
1358
+ fail(
1359
+ 'A28_RIBBON_ROWS_SHARE_WEIGHTS',
1360
+ `ribbon "${mesh.name}" row ${i} has [${shape(left)}] on one side and [${shape(right)}] on the other; its width would change with the chain`,
1361
+ );
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ });
1367
+
1368
+ // --- A29: inward travel stops where the two masses meet ------------------
1369
+ //
1370
+ // 🎯 The rule: inward travel goes at most as far as the point where the moving
1371
+ // mass touches the part that occludes it. That distance is MEASURED off the two
1372
+ // plates (`tools/measure_contact_depth.ts`) and recorded in the manifest as
1373
+ // `stroke.contact_depth`, so the ceiling is a fact about the art rather than a
1374
+ // number somebody picked. Drive past it and the frame renders two bodies
1375
+ // interpenetrating — and NOTHING in skeleton JSON objects: the animation loads,
1376
+ // plays, and is simply wrong. Exactly the shape of silent wrongness this
1377
+ // validator exists for.
1378
+ //
1379
+ // Two things spend the same clearance and so are added together:
1380
+ // * the travel itself, a translateX on a bone in the axis subtree (A24
1381
+ // guarantees there is no hidden screen-space component to miss); and
1382
+ // * the mass bone's own inward keys. `invariants.massBone` typically hangs
1383
+ // outside the axis subtree, so its keys are screen-space by design — they
1384
+ // get projected onto the axis rather than read as axis coordinates.
1385
+ // Ignoring them would let a rig pass while a recoil key closed the last few
1386
+ // pixels of the gap.
1387
+ check('A29_STROKE_WITHIN_CONTACT_DEPTH', () => {
1388
+ const rig = input.rig;
1389
+ if (!rig) return skip('A29_STROKE_WITHIN_CONTACT_DEPTH', 'no rig info (validating a bare directory)');
1390
+ if (!rig.contactDepth) {
1391
+ return skip(
1392
+ 'A29_STROKE_WITHIN_CONTACT_DEPTH',
1393
+ 'the manifest declares no `stroke.contact_depth`, so this cut has no measured contact ceiling to hold the stroke to',
1394
+ );
1395
+ }
1396
+ const deep = deepestInwardAdvance(raw, rig);
1397
+ stats.contactDepth = rig.contactDepth;
1398
+ stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
1399
+ if (deep.total > rig.contactDepth + 1e-6) {
1400
+ fail(
1401
+ 'A29_STROKE_WITHIN_CONTACT_DEPTH',
1402
+ `${deep.describe()} but the masses meet at ${rig.contactDepth}px — the two plates would interpenetrate`,
1403
+ );
1404
+ }
1405
+ });
1406
+
1407
+ // --- A30: inward travel stops where the drawn cover runs out -------------
1408
+ //
1409
+ // 🎯 The second ceiling, and it is NOT a restatement of A29. Contact asks when
1410
+ // two masses collide; containment asks when the moving part's leading contour
1411
+ // stops being covered by the occluder's opaque footprint. Past that point the
1412
+ // part is drawn in a place the art says is hidden — and like every failure in
1413
+ // this family it is completely silent: the animation loads, plays, and shows
1414
+ // one plate passing through another.
1415
+ //
1416
+ // A cut can have either ceiling without the other, which is why they are two
1417
+ // manifest fields and two assertions. Two plates cut from ONE piece of art are
1418
+ // adjacent at rest and never "meet", so that cut has no contact ceiling at all
1419
+ // and only a containment one.
1420
+ //
1421
+ // ⚠️ Second half, and it is what keeps the first half true: the ceiling is
1422
+ // measured on the UNDEFORMED contour, by translating the plate along the axis.
1423
+ // A scale key on any bone in the axis subtree changes the contour itself, so the
1424
+ // measured number stops describing the rig — quietly, because the file still
1425
+ // validates. Rather than assert a number that no longer means anything, refuse
1426
+ // the deformation. A cut that wants squash under a declared ceiling has to
1427
+ // re-measure containment for the scaled contour and say so.
1428
+ check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => {
1429
+ const rig = input.rig;
1430
+ if (!rig) return skip('A30_STROKE_WITHIN_CAP_CONTAINMENT', 'no rig info (validating a bare directory)');
1431
+ if (!rig.capContainmentCeiling) {
1432
+ return skip(
1433
+ 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
1434
+ 'the manifest declares no `stroke.cap_containment_ceiling`, so this cut has no measured containment ceiling',
1435
+ );
1436
+ }
1437
+ const deep = deepestInwardAdvance(raw, rig);
1438
+ stats.capCeiling = rig.capContainmentCeiling;
1439
+ stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
1440
+ if (deep.total > rig.capContainmentCeiling + 1e-6) {
1441
+ fail(
1442
+ 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
1443
+ `${deep.describe()} but the leading contour leaves the occluder's opaque footprint at ` +
1444
+ `${rig.capContainmentCeiling}px — the part would be drawn where it should be covered`,
1445
+ );
1446
+ }
1447
+ const subtree = new Set(rig.axisSubtree);
1448
+ const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
1449
+ for (const [animName, anim] of Object.entries(anims)) {
1450
+ if (!isObj(anim) || !isObj(anim.bones)) continue;
1451
+ for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
1452
+ if (!subtree.has(boneName) || !isObj(timelines)) continue;
1453
+ for (const name of Object.keys(timelines)) {
1454
+ if (name !== 'scale' && name !== 'scalex' && name !== 'scaley') continue;
1455
+ fail(
1456
+ 'A30_STROKE_WITHIN_CAP_CONTAINMENT',
1457
+ `"${animName}" gives "${boneName}" a ${name} timeline while a cap-containment ceiling is declared; ` +
1458
+ 'the ceiling was measured on the undeformed contour, so a scaled plate is outside its evidence',
1459
+ );
1460
+ }
1461
+ }
1462
+ }
1463
+ });
1464
+
1465
+ // --- A18: determinism ----------------------------------------------------
1466
+ check('A18_DETERMINISTIC_EMIT', () => {
1467
+ // Same vacuous-pass trap as A09: re-gating artifacts already on disk hands
1468
+ // this assertion no second compile, and there is nothing determinate about
1469
+ // a comparison that never ran.
1470
+ if (!input.reEmit) {
1471
+ return skip('A18_DETERMINISTIC_EMIT', 'no second compile to compare against (re-gating artifacts on disk)');
1472
+ }
1473
+ if (input.reEmit.skeletonText !== input.skeletonText) {
1474
+ fail('A18_DETERMINISTIC_EMIT', 'recompiling produced a different skeleton.json');
1475
+ }
1476
+ if (input.reEmit.atlasText !== input.atlasText) {
1477
+ fail('A18_DETERMINISTIC_EMIT', 'recompiling produced a different skeleton.atlas');
1478
+ }
1479
+ });
1480
+
1481
+ return { failures, passed, skipped, profileSkipped, profile, stats };
1482
+ }
1483
+
1484
+ /**
1485
+ * Deepest inward advance the animation data asks for, in axis pixels.
1486
+ *
1487
+ * Shared by A29 and A30 because they bound the SAME quantity against two
1488
+ * different measured facts. Two things spend the same clearance and so are added:
1489
+ *
1490
+ * * the stroke — a translateX on a bone in the axis subtree. A24 guarantees
1491
+ * there is no hidden screen-space component to miss.
1492
+ * * the mass bone's own inward keys. It typically hangs outside the axis
1493
+ * subtree, so its keys are screen-space by design and get PROJECTED onto the
1494
+ * axis rather than read as axis coordinates. Ignoring them would let a rig
1495
+ * pass while a recoil key closed the last few pixels.
1496
+ */
1497
+ function deepestInwardAdvance(
1498
+ raw: Json | null,
1499
+ rig: NonNullable<ValidateInput['rig']>,
1500
+ ): { total: number; describe: () => string } {
1501
+ const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
1502
+ const subtree = new Set(rig.axisSubtree);
1503
+ let strokeMax = 0;
1504
+ let strokeWhere = '';
1505
+ let massMax = 0;
1506
+ let massWhere = '';
1507
+ for (const [animName, anim] of Object.entries(anims)) {
1508
+ if (!isObj(anim) || !isObj(anim.bones)) continue;
1509
+ for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
1510
+ if (!isObj(timelines)) continue;
1511
+ const keys = (timelines as Json).translate;
1512
+ if (!Array.isArray(keys)) continue;
1513
+ for (const key of keys) {
1514
+ if (!isObj(key)) continue;
1515
+ if (subtree.has(boneName)) {
1516
+ // +x is inward along the axis; a retracted key is negative and spends
1517
+ // no clearance, so only the inward extreme matters.
1518
+ const x = Number(key.x ?? 0);
1519
+ if (Number.isFinite(x) && x > strokeMax) {
1520
+ strokeMax = x;
1521
+ strokeWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
1522
+ }
1523
+ } else if (boneName === rig.massBone && rig.inwardUnit) {
1524
+ const inward = Number(key.x ?? 0) * rig.inwardUnit[0] + Number(key.y ?? 0) * rig.inwardUnit[1];
1525
+ if (Number.isFinite(inward) && inward > massMax) {
1526
+ massMax = inward;
1527
+ massWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ return {
1534
+ total: strokeMax + massMax,
1535
+ describe: () =>
1536
+ `deepest inward advance is ${(strokeMax + massMax).toFixed(3)}px (stroke ${strokeMax.toFixed(3)} at ${strokeWhere}` +
1537
+ `${massMax > 0 ? ` + mass ${massMax.toFixed(3)} at ${massWhere}` : ''})`,
1538
+ };
1539
+ }
1540
+
1541
+ /**
1542
+ * Decode a weighted mesh's `vertices` run into per-vertex (boneIndex, weight).
1543
+ *
1544
+ * The encoding carries no marker at all — weighted versus unweighted is decided
1545
+ * by a length comparison — so every assertion that talks
1546
+ * about weights has to walk the run itself.
1547
+ */
1548
+ function meshWeightsOf(mesh: MeshAttachment): Array<Array<{ bone: number; weight: number }>> {
1549
+ const out: Array<Array<{ bone: number; weight: number }>> = [];
1550
+ if (!mesh.bones) return out;
1551
+ let bi = 0;
1552
+ let vi = 0;
1553
+ while (bi < mesh.bones.length) {
1554
+ const boneCount = mesh.bones[bi++];
1555
+ const vertex: Array<{ bone: number; weight: number }> = [];
1556
+ for (let n = 0; n < boneCount; n++, bi++, vi += 3) {
1557
+ vertex.push({ bone: mesh.bones[bi], weight: mesh.vertices[vi + 2] });
1558
+ }
1559
+ out.push(vertex);
1560
+ }
1561
+ return out;
1562
+ }
1563
+
1564
+ export function reportLines(report: ValidateReport): string[] {
1565
+ const lines: string[] = [];
1566
+ // The profile goes FIRST and names what it left out. A report that says
1567
+ // "green" without saying which rulebook produced it is the one thing this
1568
+ // switch could make worse than no switch: `--profile spine` green means
1569
+ // "valid Spine", never "passes the renderer policy".
1570
+ const renderer = report.profileSkipped.filter((p) => p.kind === 'renderer').length;
1571
+ const archetype = report.profileSkipped.filter((p) => p.kind === 'archetype').length;
1572
+ lines.push(
1573
+ report.profileSkipped.length === 0
1574
+ ? ` .. profile ${report.profile} — every assertion applies`
1575
+ : ` .. profile ${report.profile} — ${renderer} renderer-policy and ${archetype} archetype assertion(s) do not apply`,
1576
+ );
1577
+ for (const name of report.passed) lines.push(` PASS ${name}`);
1578
+ for (const s of report.skipped) lines.push(` SKIP ${s.assertion}: ${s.reason}`);
1579
+ for (const p of report.profileSkipped) lines.push(` PROF ${p.assertion}: ${p.kind} rule, not in profile "${report.profile}"`);
1580
+ for (const f of report.failures) lines.push(` FAIL ${f.assertion}: ${f.detail}`);
1581
+ return lines;
1582
+ }
1583
+
1584
+ export function atlasDirOf(atlasPath: string): string {
1585
+ return dirname(resolve(atlasPath));
1586
+ }