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/README.md +231 -6
- package/cli.ts +39 -5
- package/docs/AUTHORING.md +611 -24
- package/docs/SPEC_COVERAGE.md +16 -10
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1013 -92
- package/src/compile.ts +325 -6
- package/src/ladder.ts +1 -1
- package/src/render.ts +22 -2
- package/src/rig.ts +169 -6
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
package/src/rig.ts
CHANGED
|
@@ -395,18 +395,99 @@ export interface RigMeshAttachment {
|
|
|
395
395
|
}
|
|
396
396
|
|
|
397
397
|
/**
|
|
398
|
-
* The
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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: '
|
|
481
|
+
type: 'point' | 'path' | 'linkedmesh';
|
|
406
482
|
[field: string]: unknown;
|
|
407
483
|
}
|
|
408
484
|
|
|
409
|
-
export type RigAttachment =
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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.
|
|
46
|
-
* (`spine-html`)
|
|
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) {
|