spine-rigc 0.2.1 → 0.3.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/rig.ts CHANGED
@@ -395,18 +395,99 @@ export interface RigMeshAttachment {
395
395
  }
396
396
 
397
397
  /**
398
- * The four types the format holds and rigc's emitter does not cover yet. They
399
- * are in the type so a spec can *say* them and get a named
400
- * `NotImplementedError`; the alternative is the parser's own behaviour, which is
401
- * to return `null` for an unknown `type` and drop the attachment without a word
398
+ * The geometry every non-region attachment shares: a polygon, either pinned to
399
+ * one bone or weighted across several.
400
+ *
401
+ * ⭐ `vertexCount` is REQUIRED and cross-checked, and that is the whole design of
402
+ * these two types. A mesh gets its vertex count from `uvs.length`, so there is
403
+ * nothing to state; a bounding box and a clipping polygon have no uvs, and the
404
+ * parser reads `map.vertexCount << 1` — with the field absent that is
405
+ * `undefined << 1` = **0**, so `readVertices` takes the weighted branch,
406
+ * decodes coordinates as a weight run, and hands back an attachment with no
407
+ * vertices at all. Nothing throws. So the count is declared here and checked
408
+ * against whichever encoding the spec used.
409
+ *
410
+ * The two encodings are the mesh's, unchanged, and for the same reason:
411
+ * `weights` binds by NAME and is the default; `vertices` is either an unweighted
412
+ * `x, y` run (one pair per vertex) or Spine's index-encoded weighted run, and
413
+ * the second of those needs `boneIndexing: "raw"` said out loud because a bone
414
+ * inserted anywhere above shifts every index in silence (issue #45).
415
+ */
416
+ export interface RigVertexGeometry {
417
+ /** Required. No parser default: absent reads as 0 and the polygon vanishes. */
418
+ vertexCount: number;
419
+ /**
420
+ * Unweighted `x, y` pairs (`vertices.length === vertexCount * 2`), or Spine's
421
+ * weighted run behind `boneIndexing: "raw"`. Mutually exclusive with `weights`.
422
+ */
423
+ vertices?: number[];
424
+ /** Weighted geometry bound by name — one entry per vertex. The default form. */
425
+ weights?: RigMeshBinding[][];
426
+ /** `"raw"` opts a `vertices` weighted run into the index encoding. */
427
+ boneIndexing?: 'name' | 'raw';
428
+ /** `rrggbbaa`. Editor affordance: the colour the box is drawn in. */
429
+ color?: string;
430
+ }
431
+
432
+ /**
433
+ * `type: "boundingbox"` (`SkeletonJson.ts:560-567`).
434
+ *
435
+ * **When you need one:** a polygon the game can hit-test against — a hurt box, a
436
+ * pick region, a trigger volume — that moves with the skeleton and draws
437
+ * nothing. It is the only attachment type whose entire purpose is outside the
438
+ * renderer, which is why it has no `path`, no size and no uvs.
439
+ */
440
+ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
441
+ type: 'boundingbox';
442
+ }
443
+
444
+ /**
445
+ * `type: "clipping"` (`SkeletonJson.ts:635-651`).
446
+ *
447
+ * **When you need one:** a mask. The polygon clips every slot drawn from the one
448
+ * carrying it up to and including `end`, so a window, a portal or a wipe is one
449
+ * attachment rather than a second set of art.
450
+ *
451
+ * ⚠️ `end` is resolved with `skeletonData.findSlot(end)`, which returns **null**
452
+ * on a miss and assigns that null without complaint (`:626-627`). The clip then
453
+ * never ends — it runs to the bottom of the draw order and takes every slot
454
+ * below it with it. rigc refuses a name the rig does not declare.
455
+ */
456
+ export interface RigClippingAttachment extends RigVertexGeometry {
457
+ type: 'clipping';
458
+ /**
459
+ * The last slot this clip applies to, by name. Absent leaves `endSlot` null,
460
+ * which is the parser's own encoding for "clip everything after this one".
461
+ */
462
+ end?: string;
463
+ /** 4.3. Default false. */
464
+ convex?: boolean;
465
+ /** 4.3. Default false. */
466
+ inverse?: boolean;
467
+ }
468
+
469
+ /**
470
+ * The three types the format holds and rigc's emitter does not cover. They are
471
+ * in the type so a spec can *say* them and get a named `NotImplementedError`;
472
+ * the alternative is the parser's own behaviour, which is to return `null` for
473
+ * an unknown `type` and drop the attachment without a word
402
474
  * (`SkeletonJson.ts:653`).
475
+ *
476
+ * 🚧 None of the three appears anywhere in the benchmark corpus
477
+ * (SPEC_COVERAGE parts 3-1 and 4-2), so none is on the ladder's critical path —
478
+ * which is the reason they are deferred rather than an oversight.
403
479
  */
404
480
  export interface RigUnimplementedAttachment {
405
- type: 'boundingbox' | 'point' | 'clipping' | 'path' | 'linkedmesh';
481
+ type: 'point' | 'path' | 'linkedmesh';
406
482
  [field: string]: unknown;
407
483
  }
408
484
 
409
- export type RigAttachment = RigRegionAttachment | RigMeshAttachment | RigUnimplementedAttachment;
485
+ export type RigAttachment =
486
+ | RigRegionAttachment
487
+ | RigMeshAttachment
488
+ | RigBoundingBoxAttachment
489
+ | RigClippingAttachment
490
+ | RigUnimplementedAttachment;
410
491
 
411
492
  /** `slotName -> placeholderName -> attachment` (`SkeletonJson.ts:431-439`). */
412
493
  export type RigSkin = Record<string, Record<string, RigAttachment>>;
@@ -549,6 +630,43 @@ export type RigConstraint =
549
630
  | RigPhysicsConstraint
550
631
  | RigUnimplementedConstraint;
551
632
 
633
+ // ---------------------------------------------------------------------------
634
+ // events — `root.events` (SkeletonJson.ts:469-484), an OBJECT, not an array
635
+ // ---------------------------------------------------------------------------
636
+
637
+ /**
638
+ * One event **definition**: a name the skeleton owns, plus the payload a firing
639
+ * carries when the animation does not override it.
640
+ *
641
+ * ⭐ The declaration lives in the rig spec and the firings live in the motion
642
+ * spec, for the same reason slots live here and their colour keys live there:
643
+ * the name is structure — the runtime looks it up, the game listens for it —
644
+ * and *when* it fires is time. `skeletonData.findEvent` resolves an animation's
645
+ * key against this table and **throws** on a miss (`:1244`), so an animation
646
+ * that names an event nobody declared does not load at all. rigc refuses it at
647
+ * compile instead, where the message can name the file that has to change.
648
+ *
649
+ * ⚠️ `volume` and `balance` are read **only when `audio` is set** (`:478-481`).
650
+ * Declared without one they are dropped in silence, so rigc refuses that pairing
651
+ * rather than emitting two numbers the runtime will never look at.
652
+ */
653
+ export interface RigEvent {
654
+ /** Default 0. The `int` payload every firing inherits unless it overrides it. */
655
+ int?: number;
656
+ /** Default 0. */
657
+ float?: number;
658
+ /** Default `""`. */
659
+ string?: string;
660
+ /**
661
+ * Audio path the editor recorded for this event. Nonessential to playback —
662
+ * no runtime here loads it — but it is what makes `volume`/`balance` legible.
663
+ */
664
+ audio?: string;
665
+ /** Only read when `audio` is set. */
666
+ volume?: number;
667
+ balance?: number;
668
+ }
669
+
552
670
  // ---------------------------------------------------------------------------
553
671
  // invariants — what skeleton JSON cannot say about itself
554
672
  // ---------------------------------------------------------------------------
@@ -619,6 +737,13 @@ export interface RigSpec {
619
737
  /** At least `default`, which becomes `skeletonData.defaultSkin` (`:441`). */
620
738
  skins?: Record<string, RigSkin>;
621
739
  constraints?: RigConstraint[];
740
+ /**
741
+ * `eventName -> payload defaults`. Emitted as `root.events`, which is an
742
+ * OBJECT keyed by name and not an array. The motion spec's per-animation
743
+ * `events` timeline fires them; a firing whose name is not a key here is a
744
+ * compile error, because the parser throws on it at load.
745
+ */
746
+ events?: Record<string, RigEvent>;
622
747
  invariants?: RigInvariants;
623
748
  }
624
749
 
@@ -727,5 +852,43 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
727
852
  constraintNames.add(constraint.name);
728
853
  }
729
854
 
855
+ if (raw.events !== undefined) {
856
+ if (!isObj(raw.events)) {
857
+ throw new CompileError(
858
+ `${where}: "events" is an object keyed by event name (\`{ "footstep": {} }\`), not an array — the format's own shape`,
859
+ );
860
+ }
861
+ for (const [name, def] of Object.entries(raw.events)) {
862
+ if (name.length === 0) throw new CompileError(`${where}: an event has an empty name`);
863
+ if (!isObj(def)) {
864
+ throw new CompileError(`${where}: event "${name}" must be an object of payload defaults (use {} for none)`);
865
+ }
866
+ for (const field of ['int', 'float', 'volume', 'balance'] as const) {
867
+ const v = def[field];
868
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) {
869
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(v)}, which is not a finite number`);
870
+ }
871
+ }
872
+ if (def.int !== undefined && !Number.isInteger(def.int)) {
873
+ throw new CompileError(`${where}: event "${name}" has int ${JSON.stringify(def.int)}; the payload is an integer`);
874
+ }
875
+ for (const field of ['string', 'audio'] as const) {
876
+ if (def[field] !== undefined && typeof def[field] !== 'string') {
877
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(def[field])}, which is not a string`);
878
+ }
879
+ }
880
+ // SkeletonJson.ts:478-481 reads these two ONLY inside `if (data.audioPath)`.
881
+ // Without an audio path they are dropped with no error, so a spec that
882
+ // wrote them down would carry a number no runtime ever reads.
883
+ for (const field of ['volume', 'balance'] as const) {
884
+ if (def[field] !== undefined && def.audio === undefined) {
885
+ throw new CompileError(
886
+ `${where}: event "${name}" declares ${field} but no "audio"; the parser reads ${field} only when an audio path is set, so it would be dropped in silence`,
887
+ );
888
+ }
889
+ }
890
+ }
891
+ }
892
+
730
893
  return spec;
731
894
  }
package/src/timelines.ts CHANGED
@@ -87,11 +87,15 @@ const EVENT_CHANNELS: Record<string, number | null> = { events: null };
87
87
  * How far past an animation's declared `duration` a key time may land: one step
88
88
  * of the grid every key time is rounded onto.
89
89
  *
90
- * The compiler emits key times through `r6`, so 1e-6 s is the finest distinction
91
- * a key can make and half a step is the most a *correct* key can miss its target
92
- * by. A key the author put exactly ON a duration of 68/12 s emits as 5.666667 —
93
- * 3.3e-7 s late, and legal. Anything a whole step further was authored past the
94
- * end, not rounded onto it.
90
+ * The compiler quantises key times onto that grid with `keyTime`, which rounds
91
+ * DOWN (issue #99), so 1e-6 s is the finest distinction a key can make and a
92
+ * *correct* key now misses its target only on the early side: a key the author put
93
+ * exactly ON a duration of 68/12 s emits as 5.666666 rather than past it. What
94
+ * still needs the tolerance is the other side of the same rule — `validate` re-runs
95
+ * it on an emitted file read back through a **Float32Array**, whose steps are
96
+ * coarser than this one and round both ways, and on artifacts no rigc compile ever
97
+ * touched. Anything a whole step past a declared duration was authored there, not
98
+ * rounded onto it.
95
99
  *
96
100
  * ⚠️ `FRAME` (1/60 s) is the wrong tolerance for this, which is why the constant
97
101
  * is separate rather than reused: 1/60 s answers "is the DECLARED DURATION
package/src/types.ts CHANGED
@@ -303,6 +303,33 @@ export interface MotionDrawOrderKey {
303
303
  offsets?: MotionDrawOrderOffset[];
304
304
  }
305
305
 
306
+ /**
307
+ * One firing of a declared event, at one time.
308
+ *
309
+ * ⚠️ Like `drawOrder` and unlike a `track`, this timeline names **no target**:
310
+ * 4.3 writes it as `animations.<a>.events` beside `bones` and `slots`
311
+ * (SPEC_COVERAGE part 1-8), and there is one per animation. The `name` picks
312
+ * an entry out of the rig spec's `events` table; the optional payload fields
313
+ * override that entry's defaults for this firing only.
314
+ *
315
+ * A key with no `int`/`float`/`string` inherits the event's setup payload
316
+ * (`:1250-1252`) — which is what the editor writes, and why `{ "t": 0.5,
317
+ * "name": "footstep" }` is the common shape.
318
+ */
319
+ export interface MotionEventKey {
320
+ /** Time in seconds. */
321
+ t: number;
322
+ /** An event the rig spec declares. A miss throws in the parser; rigc refuses it. */
323
+ name: string;
324
+ /** Payload overrides for this firing. Omit to inherit the event's defaults. */
325
+ int?: number;
326
+ float?: number;
327
+ string?: string;
328
+ /** Read only when the declared event carries an `audio` path — see `RigEvent`. */
329
+ volume?: number;
330
+ balance?: number;
331
+ }
332
+
306
333
  export interface MotionAnimation {
307
334
  /** Declared, then verified against the compiled result (rule 4). */
308
335
  duration: number;
@@ -320,6 +347,11 @@ export interface MotionAnimation {
320
347
  * time. First needed at ladder rung 5.
321
348
  */
322
349
  drawOrder?: MotionDrawOrderKey[];
350
+ /**
351
+ * The event timeline. One per animation, names no target, and for the same
352
+ * reason `drawOrder` is not a `track`. First needed at the spineboy rung.
353
+ */
354
+ events?: MotionEventKey[];
323
355
  }
324
356
 
325
357
  /**
@@ -442,7 +474,37 @@ export interface SpineMeshAttachment {
442
474
  color?: string;
443
475
  }
444
476
 
445
- export type SpineAttachment = SpineRegionAttachment | SpineMeshAttachment;
477
+ /**
478
+ * The two vertex-only attachments: a polygon and nothing else.
479
+ *
480
+ * `vertexCount` is not optional the way a mesh's is absent-by-design: the parser
481
+ * reads `map.vertexCount << 1`, so an omission is `0` and `readVertices` decodes
482
+ * the coordinate array as a weight run and stores nothing.
483
+ */
484
+ export interface SpineBoundingBoxAttachment {
485
+ type: 'boundingbox';
486
+ vertexCount: number;
487
+ /** Unweighted x/y pairs, or the weighted run — same encoding as a mesh's. */
488
+ vertices: number[];
489
+ color?: string;
490
+ }
491
+
492
+ export interface SpineClippingAttachment {
493
+ type: 'clipping';
494
+ /** The last slot the clip applies to. Absent = to the bottom of the order. */
495
+ end?: string;
496
+ convex?: boolean;
497
+ inverse?: boolean;
498
+ vertexCount: number;
499
+ vertices: number[];
500
+ color?: string;
501
+ }
502
+
503
+ export type SpineAttachment =
504
+ | SpineRegionAttachment
505
+ | SpineMeshAttachment
506
+ | SpineBoundingBoxAttachment
507
+ | SpineClippingAttachment;
446
508
 
447
509
  export type SpineTimelineKey = Record<string, unknown>;
448
510
 
@@ -469,6 +531,11 @@ export interface SpineSkeletonJson {
469
531
  slots: SpineSlot[];
470
532
  constraints?: SpineConstraint[];
471
533
  skins: Array<{ name: string; attachments: Record<string, Record<string, SpineAttachment>> }>;
534
+ /**
535
+ * Event definitions, keyed by name (`SkeletonJson.ts:451-464`). An object, not
536
+ * an array — the one top-level collection in the format that is.
537
+ */
538
+ events?: Record<string, SpineEvent>;
472
539
  animations: Record<
473
540
  string,
474
541
  {
@@ -477,10 +544,22 @@ export interface SpineSkeletonJson {
477
544
  physics?: Record<string, Record<string, SpineTimelineKey[]>>;
478
545
  /** Whole-animation timeline: no target name, one array per animation. */
479
546
  drawOrder?: SpineTimelineKey[];
547
+ /** The other whole-animation timeline; same shape, same reason. */
548
+ events?: SpineTimelineKey[];
480
549
  }
481
550
  >;
482
551
  }
483
552
 
553
+ /** One entry of the emitted `events` map: the payload a firing inherits. */
554
+ export interface SpineEvent {
555
+ int?: number;
556
+ float?: number;
557
+ string?: string;
558
+ audio?: string;
559
+ volume?: number;
560
+ balance?: number;
561
+ }
562
+
484
563
  // ---------------------------------------------------------------------------
485
564
  // Compiler result
486
565
  // ---------------------------------------------------------------------------
package/src/validate.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  AnimationState,
20
20
  AnimationStateData,
21
21
  AtlasAttachmentLoader,
22
+ BoundingBoxAttachment,
22
23
  ClippingAttachment,
23
24
  MeshAttachment,
24
25
  Physics,
@@ -42,8 +43,9 @@ export interface Failure {
42
43
  *
43
44
  * ⭐ The distinction this draws is the difference between "wrong" and "not how we
44
45
  * 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
46
+ * anybody else's data. Fourteen of the 34 assertions are policy — seven for one
47
+ * renderer (`spine-html`) and one project's canvas budget, seven for rigc's own
48
+ * formations — and every one of them fires
47
49
  * on real, correct, editor-produced Spine data — the official example projects
48
50
  * carry clipping attachments, unweighted meshes, 116-triangle meshes and packed
49
51
  * atlases, all of which are perfectly valid and none of which spine-html likes.
@@ -113,6 +115,8 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
113
115
  A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
114
116
  A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
115
117
  A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
118
+ A32_EVENT_KEYS_RESOLVE: 'validity',
119
+ A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
116
120
  };
117
121
 
118
122
  export interface ValidateInput {
@@ -370,6 +374,81 @@ export function validate(input: ValidateInput): ValidateReport {
370
374
  if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
371
375
  });
372
376
 
377
+ // --- A32: every event key fires a declared event, in order ----------------
378
+ //
379
+ // The event timeline's three failure modes, and only the first is loud:
380
+ //
381
+ // 1. **An undeclared name.** `findEvent` returns null and `readAnimation`
382
+ // throws `Event not found` (SkeletonJson.ts:1244). A00 would catch it, but
383
+ // as a parser message about a name with no context; this one says which
384
+ // animation, which key, and what the skeleton does declare.
385
+ // 2. **Times out of order.** `readAnimation` writes frame `i` from key `i` in
386
+ // ARRAY order and never sorts, so a decreasing time builds an
387
+ // `EventTimeline` whose frames run backwards. It loads clean, and the
388
+ // firings behind the fold simply never come out. Equal times are fine —
389
+ // two events on one frame is ordinary — so this is non-decreasing.
390
+ // 3. **`volume`/`balance` on a silent event.** `:1254-1257` reads them only
391
+ // inside `if (event.data.audioPath)`, so on an event with no `audio` they
392
+ // are two numbers in the file that no runtime will ever read.
393
+ //
394
+ // It runs on the raw JSON rather than on the loaded data because the loaded
395
+ // `Event` no longer remembers which fields the file wrote: an override that was
396
+ // dropped and an override that matched the default are the same object.
397
+ check('A32_EVENT_KEYS_RESOLVE', () => {
398
+ if (!raw) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
399
+ if (!isObj(raw.animations)) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton declares no animations');
400
+ const declared = isObj(raw.events) ? (raw.events as Json) : {};
401
+ const known = Object.keys(declared);
402
+ let sawATimeline = false;
403
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
404
+ if (!isObj(anim) || !Array.isArray(anim.events)) continue;
405
+ sawATimeline = true;
406
+ let previous = -Infinity;
407
+ (anim.events as unknown[]).forEach((key, k) => {
408
+ const at = `animation "${animName}" event key ${k}`;
409
+ if (!isObj(key) || typeof key.name !== 'string') {
410
+ fail('A32_EVENT_KEYS_RESOLVE', `${at}: an event key needs a string "name"`);
411
+ return;
412
+ }
413
+ const definition = declared[key.name];
414
+ if (definition === undefined) {
415
+ fail(
416
+ 'A32_EVENT_KEYS_RESOLVE',
417
+ `${at}: fires "${key.name}", which the skeleton's events block does not declare` +
418
+ (known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
419
+ );
420
+ return;
421
+ }
422
+ // `time` defaults to 0 when absent (`:1247`), which is what the editor
423
+ // writes for a firing on frame 0.
424
+ const time = key.time === undefined ? 0 : key.time;
425
+ if (typeof time !== 'number' || !Number.isFinite(time)) {
426
+ fail('A32_EVENT_KEYS_RESOLVE', `${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
427
+ return;
428
+ }
429
+ if (time < previous) {
430
+ fail(
431
+ 'A32_EVENT_KEYS_RESOLVE',
432
+ `${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
433
+ 'array order and never sorts them, so the earlier firing is unreachable',
434
+ );
435
+ }
436
+ previous = Math.max(previous, time);
437
+ const hasAudio = isObj(definition) && typeof definition.audio === 'string';
438
+ for (const field of ['volume', 'balance'] as const) {
439
+ if (key[field] !== undefined && !hasAudio) {
440
+ fail(
441
+ 'A32_EVENT_KEYS_RESOLVE',
442
+ `${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
443
+ `${field} only for an event that has one, so it is dropped in silence`,
444
+ );
445
+ }
446
+ }
447
+ });
448
+ }
449
+ if (!sawATimeline) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
450
+ });
451
+
373
452
  // --- A: the round trip ----------------------------------------------------
374
453
  // The two loaded objects come back OUT of the assertion rather than being
375
454
  // assigned into it. Everything below reads them, and a `let` written inside a
@@ -563,6 +642,117 @@ export function validate(input: ValidateInput): ValidateReport {
563
642
  }
564
643
  });
565
644
 
645
+ // --- A33: bounding boxes and clipping polygons hold a real polygon -------
646
+ //
647
+ // These two types are the same shape — a polygon and nothing else — and they
648
+ // fail the same three ways, all three silent:
649
+ //
650
+ // 1. **A missing or wrong `vertexCount`.** The parser reads
651
+ // `map.vertexCount << 1` and hands it to `readVertices` as the length to
652
+ // expect (`:552`, `:632`). `undefined << 1` is 0, so an omission makes
653
+ // the coordinate array read as a WEIGHTED run: it decodes numbers as
654
+ // bone counts and weights, and the attachment ends up with no vertices
655
+ // at all. Nothing throws, and neither type draws a pixel, so nothing
656
+ // downstream notices either.
657
+ // 2. **A weighted run that does not decode to that many vertices.** Same
658
+ // trap as a mesh's (A04), minus the uvs that would have caught it.
659
+ // 3. **A clipping `end` naming a slot that is not there.**
660
+ // `skeletonData.findSlot` returns null on a miss and `:626-627` assigns
661
+ // the null, so the clip does not end where it was told to — it runs to
662
+ // the bottom of the draw order and takes every slot below it with it.
663
+ // Checked on the raw JSON, because a null `endSlot` and an `end` that
664
+ // was never written are the same loaded object.
665
+ check('A33_VERTEX_ATTACHMENT_GEOMETRY', () => {
666
+ const polygons: Array<{ what: string; att: BoundingBoxAttachment | ClippingAttachment }> = [];
667
+ for (const skin of data.skins) {
668
+ for (const entry of skin.getAttachments()) {
669
+ const att = entry.attachment;
670
+ if (att instanceof BoundingBoxAttachment) polygons.push({ what: `bounding box "${att.name}"`, att });
671
+ else if (att instanceof ClippingAttachment) polygons.push({ what: `clipping attachment "${att.name}"`, att });
672
+ }
673
+ }
674
+ const slotNames = new Set(data.slots.map((s) => s.name));
675
+ let endsChecked = 0;
676
+ if (raw && Array.isArray(raw.skins)) {
677
+ for (const skin of raw.skins as unknown[]) {
678
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
679
+ for (const [slotName, perSlot] of Object.entries(skin.attachments as Json)) {
680
+ if (!isObj(perSlot)) continue;
681
+ for (const [placeholder, att] of Object.entries(perSlot)) {
682
+ if (!isObj(att) || att.type !== 'clipping' || att.end === undefined) continue;
683
+ endsChecked++;
684
+ if (typeof att.end !== 'string' || !slotNames.has(att.end)) {
685
+ fail(
686
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
687
+ `clipping attachment "${placeholder}" on slot "${slotName}" ends at ${JSON.stringify(att.end)}, ` +
688
+ 'which is not a slot of this skeleton — the clip would run to the bottom of the draw order',
689
+ );
690
+ }
691
+ }
692
+ }
693
+ }
694
+ }
695
+ if (polygons.length === 0 && endsChecked === 0) {
696
+ return skip('A33_VERTEX_ATTACHMENT_GEOMETRY', 'the skeleton carries no bounding box and no clipping attachment');
697
+ }
698
+ for (const { what, att } of polygons) {
699
+ const length = att.worldVerticesLength;
700
+ if (!Number.isInteger(length) || length < 6 || length % 2 !== 0) {
701
+ fail(
702
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
703
+ `${what} loaded worldVerticesLength ${length}; a polygon is an even count of at least 6 (3 vertices). ` +
704
+ 'A missing "vertexCount" reads as 0 and takes the polygon with it',
705
+ );
706
+ continue;
707
+ }
708
+ const vertexCount = length / 2;
709
+ if (!att.bones) {
710
+ if (att.vertices.length !== length) {
711
+ fail(
712
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
713
+ `${what} declares ${vertexCount} vertices but holds ${att.vertices.length} unweighted numbers ` +
714
+ `(expected ${length}); the parser reads that mismatch as a weighted run`,
715
+ );
716
+ }
717
+ continue;
718
+ }
719
+ // Weighted: `bones` is boneCount, (index × boneCount), repeated, and
720
+ // `vertices` holds x, y, weight per binding.
721
+ let decoded = 0;
722
+ let bindings = 0;
723
+ let ok = true;
724
+ for (let i = 0; i < att.bones.length; decoded++) {
725
+ const count = att.bones[i++];
726
+ if (!Number.isInteger(count) || count < 1 || i + count > att.bones.length) {
727
+ fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} vertex ${decoded} claims ${count} bone(s); the run is malformed`);
728
+ ok = false;
729
+ break;
730
+ }
731
+ for (let k = 0; k < count; k++, i++) {
732
+ const index = att.bones[i];
733
+ if (index < 0 || index >= data.bones.length) {
734
+ fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} vertex ${decoded} references bone index ${index}`);
735
+ ok = false;
736
+ }
737
+ }
738
+ bindings += count;
739
+ }
740
+ if (!ok) continue;
741
+ if (decoded !== vertexCount) {
742
+ fail(
743
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
744
+ `${what} declares ${vertexCount} vertices and its weighted run decodes to ${decoded}`,
745
+ );
746
+ }
747
+ if (att.vertices.length !== bindings * 3) {
748
+ fail(
749
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
750
+ `${what} has ${bindings} binding(s) and ${att.vertices.length} weight numbers (expected ${bindings * 3})`,
751
+ );
752
+ }
753
+ }
754
+ });
755
+
566
756
  // --- A11 / A13 / A14: renderer + canvas budgets ----
567
757
  check('A11_NO_CLIPPING_ATTACHMENTS', () => {
568
758
  if (clippingCount > 0) {