spine-rigc 0.20.3 → 0.22.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/keys.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The other half of "the compiler never invents a value that is not in the
3
+ * spec": **the compiler never discards a value that is in the spec.**
4
+ *
5
+ * `src/compile.ts` reads an input object by naming the fields it knows — a
6
+ * literal list walked by a `copy` helper, or a run of `if (spec.x !== undefined)`
7
+ * lines. Either spelling asks the same question, *what does the emitter want?*,
8
+ * and neither ever asks the opposite one, *what does this file actually say?* So
9
+ * a key outside the known set was never looked at, never mentioned and never
10
+ * emitted, and the build exited 0 with every assertion green (issue #545).
11
+ *
12
+ * That is the input-side twin of the silence the whole tool exists to remove.
13
+ * A missing number is already a `CompileError` naming the field; an extra one
14
+ * was nothing at all — and the extra one is the worse of the two, because an
15
+ * invented value at least appears in the output where somebody can see it.
16
+ *
17
+ * ## What this module is, and what it is not
18
+ *
19
+ * It is one refusal and the near-miss search that makes the refusal a repair. It
20
+ * is **not** a schema: the known-key sets live beside the shapes they describe
21
+ * (`RIG_KEYS` in [`rig.ts`](rig.ts), `MOTION_KEYS` in [`motion.ts`](motion.ts)),
22
+ * because a set of key names two files away from its interface is a set that
23
+ * drifts from it. `KEY01` in `selftest.ts` derives each set from the interface's
24
+ * own source text and compares, so the pair cannot drift in silence, and `KEY02`
25
+ * refuses a declared key that occurs nowhere else in the tree — which is the
26
+ * exact shape `scaleYMode` had.
27
+ *
28
+ * ## Why the refusal is at parse time rather than at emit time
29
+ *
30
+ * The alternative considered was to record the keys the emitter actually
31
+ * touches and subtract them afterwards — no table at all, and therefore no
32
+ * drift. It is rejected for two measured reasons:
33
+ *
34
+ * - **It answers a different question.** `buildRigMesh` returns at
35
+ * `if (att.generator)` before reading `width`, `hull` or `edges`, so a
36
+ * recorder would refuse `"width"` on a generated mesh and accept it on an
37
+ * authored one. That makes the accepted key set a property of the file's own
38
+ * values rather than of the format, and the format's key set is what an
39
+ * agent authoring against it has to be told.
40
+ * - **It cannot reach what the emitter never visits.** The emit loop walks the
41
+ * rig's slots and skips one with no attachments before it reads `setup` —
42
+ * the blind spot issue #293 was lost in for three weeks and `parseMotionSpec`
43
+ * was written to remove. A recorder rebuilds it.
44
+ */
45
+ import { CompileError } from './errors.ts';
46
+
47
+ /**
48
+ * Levenshtein distance. Only ever called on the losing side of a refusal, so the
49
+ * O(n*m) table is free and a cheaper heuristic (shared prefix, substring) would
50
+ * miss the commonest real case — a transposition or one wrong character in a
51
+ * hand-typed name.
52
+ */
53
+ export function nameDistance(a: string, b: string): number {
54
+ const rows = a.length + 1;
55
+ const cols = b.length + 1;
56
+ let previous = new Array<number>(cols);
57
+ for (let j = 0; j < cols; j++) previous[j] = j;
58
+ for (let i = 1; i < rows; i++) {
59
+ const current = new Array<number>(cols);
60
+ current[0] = i;
61
+ for (let j = 1; j < cols; j++) {
62
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
63
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
64
+ }
65
+ previous = current;
66
+ }
67
+ return previous[cols - 1];
68
+ }
69
+
70
+ /** Up to five known names closest to the one that was not found. */
71
+ export function nearMisses(wanted: string, known: Iterable<string>): string[] {
72
+ const scored: Array<{ name: string; d: number }> = [];
73
+ for (const name of known) {
74
+ const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
75
+ // Half the name's length, floored at 2: "leg" must not suggest "arm", and a
76
+ // long name may still be recognisable through several typos.
77
+ if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
78
+ }
79
+ scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
80
+ return scored.slice(0, 5).map((s) => s.name);
81
+ }
82
+
83
+ /**
84
+ * Refuse every key of `node` that `known` does not carry.
85
+ *
86
+ * ⭐ **Every** one of them, in one message, rather than the first. The four keys
87
+ * issue #545 planted into a single constraint were four separate mistakes an
88
+ * author had made in one place, and a refusal that names one of them buys three
89
+ * more round trips through a compile that is not cheap.
90
+ *
91
+ * ⭐ The near miss is what makes this a repair rather than a lecture. Both sides
92
+ * are lower-cased before the distance is taken, so `ROTATE` is distance 0 from
93
+ * `rotate` and comes back first — a real key in the wrong case is the commonest
94
+ * of these and the one an author is least likely to spot by re-reading.
95
+ *
96
+ * `known` may be empty (a shape with no fields at all); the message then says so
97
+ * rather than printing `known: ` with nothing after it.
98
+ */
99
+ export function refuseUnknownKeys(
100
+ node: Record<string, unknown>,
101
+ known: readonly string[],
102
+ where: string,
103
+ what: string,
104
+ ): void {
105
+ const strays = Object.keys(node).filter((key) => !known.includes(key));
106
+ if (strays.length === 0) return;
107
+ const named = strays.map((key) => {
108
+ const near = nearMisses(key, known);
109
+ return near.length === 0 ? `"${key}"` : `"${key}" (did you mean ${near.map((n) => `"${n}"`).join(', ')}?)`;
110
+ });
111
+ throw new CompileError(
112
+ `${where}: ${what} has ${strays.length === 1 ? 'a key' : `${strays.length} keys`} this compiler does not read: ` +
113
+ `${named.join(', ')}. Nothing reads such a key, so it would be dropped from the emitted skeleton in silence — ` +
114
+ 'fix the spelling or remove it. ' +
115
+ (known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
116
+ );
117
+ }
package/src/motion.ts CHANGED
@@ -38,17 +38,23 @@
38
38
  * `src/trackgen.ts`) are untouched — every one of them reads the group's member
39
39
  * list or the property's projection table, neither of which is in this file.
40
40
  *
41
- * ## Unknown keys are NOT refused, because `parseRigSpec` does not refuse them
41
+ * ## Unknown keys ARE refused — issue #545
42
42
  *
43
- * ⚠️ A misspelled optional field is therefore still silent — `"easing"` for
44
- * `"ease"` plays linear and says nothing. Refusing by name would be the better
45
- * behaviour in isolation and this repository usually prefers it; it is not done
46
- * because the rig parser sets the house rule for what a spec parser is, and one
47
- * of the two formats refusing a stray key while the other shrugs is a worse
48
- * surprise than the stray key. (The 37 motion specs in the repository carry no
49
- * undeclared key at any level, so the option stays open at no migration cost.)
43
+ * ⚠️ This section said the opposite from 2026-09-03 (#321) until #545: *"a
44
+ * misspelled optional field is therefore still silent — `easing` for `ease`
45
+ * plays linear and says nothing"*, declined because `parseRigSpec` did not refuse one
46
+ * either and one format shrugging while the other refuses is a worse surprise
47
+ * than the stray key. That argument was sound and its premise is now false —
48
+ * `parseRigSpec` refuses by name, so the consistent behaviour is this one. The
49
+ * note's own parenthesis is what made it cheap: the motion specs in this
50
+ * repository carried no undeclared key at any level then and carry none now, so
51
+ * the migration cost, measured over all 39, is zero.
52
+ *
53
+ * `MOTION_KEYS` below is the key set, and the refusal itself is one helper in
54
+ * [`keys.ts`](keys.ts) shared with the rig parser.
50
55
  */
51
56
  import { CompileError } from './errors.ts';
57
+ import { refuseUnknownKeys } from './keys.ts';
52
58
  import type { MotionSpec } from './types.ts';
53
59
 
54
60
  export const MOTION_SPEC_VERSION = 'rigc-motion/1';
@@ -82,6 +88,58 @@ const PHYSICS_NUMBERS = [
82
88
  /** The two animation-level constraint families, which share one entry shape. */
83
89
  const CONSTRAINT_GROUPS = ['ik', 'transform'] as const;
84
90
 
91
+ /**
92
+ * Every key each shape of this format owns, keyed by the interface that declares
93
+ * it — the runtime shadow of types TypeScript erases, and the other half of
94
+ * `RIG_KEYS` in [`rig.ts`](rig.ts).
95
+ *
96
+ * 🔒 Held to those interfaces by `KEY01` in `selftest.ts`, which reads the
97
+ * declaring source and compares. The interfaces are spread over three modules —
98
+ * `types.ts` for the format, [`trackgen.ts`](trackgen.ts) for a track's `derive`
99
+ * and [`deformgen.ts`](deformgen.ts) for a deform key's `transform` — and the
100
+ * table is one table anyway, because what it describes is one file.
101
+ *
102
+ * ⚠️ `MotionKey.v` is deliberately not a shape here. On a group track it is a
103
+ * `MotionMemberValues` map keyed by **member name**, so every key of it is a
104
+ * name from the rig rather than a field of this format; `resolveMemberTrack`
105
+ * refuses a member the group does not have, which is the check that fits.
106
+ */
107
+ export const MOTION_KEYS = {
108
+ MotionSpec: ['spec', 'archetype', 'cut', 'note', 'easings', 'groups', 'setup', 'physics', 'animations', 'mix'],
109
+ MotionMix: ['default', 'pairs'],
110
+ MotionSetupSlot: ['attachment', 'color'],
111
+ MotionPhysics: [
112
+ 'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity',
113
+ 'mix', 'fps', 'limit', 'note',
114
+ ],
115
+ MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'drawOrder', 'events'],
116
+ MotionTrack: ['slot', 'group', 'bone', 'physics', 'path', 'slider', 'property', 'lag', 'stagger', 'keys'],
117
+ MotionKey: ['t', 'v', 'derive', 'ease', 'curve'],
118
+ MotionIkTrack: ['constraint', 'keys'],
119
+ MotionIkKey: ['t', 'mix', 'softness', 'bendPositive', 'compress', 'stretch', 'ease', 'curve'],
120
+ MotionTransformTrack: ['constraint', 'keys'],
121
+ MotionTransformKey: ['t', 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY', 'ease', 'curve'],
122
+ MotionDeformTrack: ['skin', 'slot', 'attachment', 'keys'],
123
+ MotionDeformKey: ['t', 'offset', 'fromVertex', 'vertices', 'transform', 'ease', 'curve'],
124
+ MotionDrawOrderKey: ['t', 'offsets'],
125
+ MotionDrawOrderOffset: ['slot', 'offset'],
126
+ MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
127
+ TrackDeriveTurn: ['kind', 'degrees', 'depth', 'carried', 'about'],
128
+ DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about'],
129
+ DeformAffine: ['kind', 'scale', 'about'],
130
+ DeformWave: ['kind', 'amplitude', 'wavelength', 'phase', 'along', 'axis'],
131
+ DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis'],
132
+ } as const satisfies Record<string, readonly string[]>;
133
+
134
+ /** A deform key's `transform` kinds, and the shape each one's keys come from. */
135
+ const DEFORM_TRANSFORM_SHAPE: Record<string, keyof typeof MOTION_KEYS> = {
136
+ yaw: 'DeformTurn',
137
+ pitch: 'DeformTurn',
138
+ affine: 'DeformAffine',
139
+ wave: 'DeformWave',
140
+ bend: 'DeformBend',
141
+ };
142
+
85
143
  function isObj(v: unknown): v is Record<string, unknown> {
86
144
  return typeof v === 'object' && v !== null && !Array.isArray(v);
87
145
  }
@@ -113,6 +171,16 @@ function refuse(where: string, key: string, is: unknown, hint: string): never {
113
171
  throw new CompileError(`${where}: \`${key}\` is ${describe(is)}; ${hint}`);
114
172
  }
115
173
 
174
+ /**
175
+ * The second refusal shape: **a key this format does not have**, from the one
176
+ * implementation of it. `at` is the path the messages above already print, so a
177
+ * reader meets `setup."lid_l"` whether the entry was the wrong type or carried
178
+ * the wrong field.
179
+ */
180
+ function known(node: unknown, shape: keyof typeof MOTION_KEYS, where: string, at: string): void {
181
+ if (isObj(node)) refuseUnknownKeys(node, MOTION_KEYS[shape], where, `\`${at}\``);
182
+ }
183
+
116
184
  // --- the leaf checks, each returning the value it just proved ---------------
117
185
 
118
186
  function needObj(v: unknown, where: string, key: string, hint: string): Record<string, unknown> {
@@ -196,6 +264,7 @@ function parseSetup(raw: unknown, where: string): void {
196
264
  if (entry.attachment !== undefined && entry.attachment !== null && typeof entry.attachment !== 'string') {
197
265
  refuse(where, `${key}.attachment`, entry.attachment, 'it is an attachment name, or null for "show nothing"');
198
266
  }
267
+ known(entry, 'MotionSetupSlot', where, key);
199
268
  if (entry.color !== undefined) {
200
269
  const hint = 'a setup colour is [r, g, b, a], four finite numbers in 0..1 — a channel that is not one is clamped to `NaN` and written into the slot as the text "NaN"';
201
270
  const color = needArray(entry.color, where, `${key}.color`, hint);
@@ -224,6 +293,7 @@ function parsePhysics(raw: unknown, where: string): void {
224
293
  for (const [name, entry] of Object.entries(table)) {
225
294
  const key = `physics."${name}"`;
226
295
  const spec = needObj(entry, where, key, 'a physics constraint is an object naming the bone it drives and the components it drives it in');
296
+ known(spec, 'MotionPhysics', where, key);
227
297
  needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
228
298
  for (const field of PHYSICS_NUMBERS) {
229
299
  optFinite(spec[field], where, `${key}.${field}`, 'every tuning field of a physics constraint is a finite number — a non-number is rounded to `NaN` and emitted as `null`, which the runtime reads as zero');
@@ -242,6 +312,7 @@ function parsePhysics(raw: unknown, where: string): void {
242
312
  function parseMix(raw: unknown, where: string): void {
243
313
  if (raw === undefined) return;
244
314
  const mix = needObj(raw, where, 'mix', 'it is `{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`');
315
+ known(mix, 'MotionMix', where, 'mix');
245
316
  needFinite(mix.default, where, 'mix.default', 'the default mix duration is a finite number of seconds');
246
317
  if (mix.pairs === undefined) return;
247
318
  const pairs = needArray(mix.pairs, where, 'mix.pairs', 'it is an array of `["<from>", "<to>", <seconds>]` triples');
@@ -273,9 +344,25 @@ function parseKeyEasing(key: Record<string, unknown>, where: string, at: string)
273
344
  optString(key.ease, where, `${at}.ease`, 'it names an entry of this spec\'s `easings` table, or is "stepped"');
274
345
  }
275
346
 
276
- /** One `{ t, … }` key of any family: an object, with a finite time and a string `ease`. */
277
- function parseKey(raw: unknown, where: string, at: string, hint: string): Record<string, unknown> {
347
+ /**
348
+ * One `{ t, … }` key of any family: an object, with a finite time and a string
349
+ * `ease`.
350
+ *
351
+ * `shape` is per caller because the five families' key shapes are five
352
+ * different sets — an `rgba` key's `v` is not a thing an ik key may carry, and
353
+ * an ik key's `softness` is not a thing a value track may. One shared shape here
354
+ * would accept every field of every family on all of them, which is a key set
355
+ * nothing in the format actually has.
356
+ */
357
+ function parseKey(
358
+ raw: unknown,
359
+ where: string,
360
+ at: string,
361
+ hint: string,
362
+ shape: keyof typeof MOTION_KEYS,
363
+ ): Record<string, unknown> {
278
364
  const key = needObj(raw, where, at, hint);
365
+ known(key, shape, where, at);
279
366
  parseKeyTime(key, where, at);
280
367
  parseKeyEasing(key, where, at);
281
368
  return key;
@@ -286,6 +373,7 @@ function parseTracks(raw: unknown, where: string, at: string): void {
286
373
  for (const [i, entry] of tracks.entries()) {
287
374
  const key = `${at}.tracks[${i}]`;
288
375
  const track = needObj(entry, where, key, 'a track is an object naming one target, one property and its keys');
376
+ known(track, 'MotionTrack', where, key);
289
377
  needString(track.property, where, `${key}.property`, 'a track states the property it keys — the table is AUTHORING §4.4');
290
378
  for (const field of TARGET_FIELDS) {
291
379
  optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
@@ -293,7 +381,16 @@ function parseTracks(raw: unknown, where: string, at: string): void {
293
381
  optFinite(track.lag, where, `${key}.lag`, '"lag" is seconds added to every key time of this track, so a finite number — a string is CONCATENATED onto each time and a boolean adds 1');
294
382
  optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
295
383
  const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
296
- for (const [j, k] of keys.entries()) parseKey(k, where, `${key}.keys[${j}]`, 'a key is an object of `{ t, v, … }`');
384
+ for (const [j, k] of keys.entries()) {
385
+ const at = `${key}.keys[${j}]`;
386
+ const parsed = parseKey(k, where, at, 'a key is an object of `{ t, v, … }`', 'MotionKey');
387
+ // `derive` states a generator's parameters rather than a value, and its
388
+ // shape lives with the evaluator (`src/trackgen.ts`). `yaw` and `pitch`
389
+ // are one interface — they differ in which coordinate they read, not in
390
+ // what they carry — so there is no dispatch to do here, and whether the
391
+ // kind is one of the two stays `evaluateTrackDerive`'s refusal.
392
+ known(parsed.derive, 'TrackDeriveTurn', where, `${at}.derive`);
393
+ }
297
394
  }
298
395
  }
299
396
 
@@ -303,9 +400,18 @@ function parseConstraintTracks(raw: unknown, where: string, at: string, group: (
303
400
  for (const [i, entry] of entries.entries()) {
304
401
  const key = `${at}.${group}[${i}]`;
305
402
  const track = needObj(entry, where, key, `${group === 'ik' ? 'an ik' : 'a transform'} timeline is an object of \`{ constraint, keys }\``);
403
+ known(track, group === 'ik' ? 'MotionIkTrack' : 'MotionTransformTrack', where, key);
306
404
  needString(track.constraint, where, `${key}.constraint`, `4.3 writes this group as \`${group}.<constraint>\`, so the constraint name is the only target there is`);
307
405
  const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of keys, each naming the same set of mix fields');
308
- for (const [j, k] of keys.entries()) parseKey(k, where, `${key}.keys[${j}]`, `a ${group} key is an object of \`{ t, … }\``);
406
+ for (const [j, k] of keys.entries()) {
407
+ parseKey(
408
+ k,
409
+ where,
410
+ `${key}.keys[${j}]`,
411
+ `a ${group} key is an object of \`{ t, … }\``,
412
+ group === 'ik' ? 'MotionIkKey' : 'MotionTransformKey',
413
+ );
414
+ }
309
415
  }
310
416
  }
311
417
 
@@ -315,18 +421,33 @@ function parseDeform(raw: unknown, where: string, at: string): void {
315
421
  for (const [i, entry] of entries.entries()) {
316
422
  const key = `${at}.deform[${i}]`;
317
423
  const track = needObj(entry, where, key, 'a deform timeline is an object of `{ skin?, slot, attachment, keys }`');
424
+ known(track, 'MotionDeformTrack', where, key);
318
425
  optString(track.skin, where, `${key}.skin`, 'it names the skin the attachment lives in; absent means "default"');
319
426
  needString(track.slot, where, `${key}.slot`, 'a deform timeline keys one attachment of one slot, named here');
320
427
  needString(track.attachment, where, `${key}.attachment`, "it is the attachment's placeholder name inside that skin and slot");
321
428
  const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of keys, each a sparse edit of the setup geometry');
322
- for (const [j, k] of keys.entries()) parseKey(k, where, `${key}.keys[${j}]`, 'a deform key is an object of `{ t, vertices? | transform? }`');
429
+ for (const [j, k] of keys.entries()) {
430
+ const at = `${key}.keys[${j}]`;
431
+ const parsed = parseKey(k, where, at, 'a deform key is an object of `{ t, vertices? | transform? }`', 'MotionDeformKey');
432
+ // `transform` is five kinds sharing one field name, and they share almost
433
+ // nothing else: `wave` carries `wavelength` and `bend` carries `power`, so
434
+ // checking either against the union's flattened keys would accept both on
435
+ // both. An unrecognised `kind` is `evaluateDeformTransform`'s refusal,
436
+ // which names the five.
437
+ if (isObj(parsed.transform)) {
438
+ const shape = DEFORM_TRANSFORM_SHAPE[String(parsed.transform.kind)];
439
+ if (shape !== undefined) known(parsed.transform, shape, where, `${at}.transform`);
440
+ }
441
+ }
323
442
  }
324
443
  }
325
444
 
326
445
  function parseEvents(raw: unknown, where: string, at: string): void {
327
446
  if (raw === undefined) return;
328
447
  const keys = needArray(raw, where, `${at}.events`, 'it is an array of `{ t, name }` firings — one timeline per animation, naming no target');
329
- for (const [i, k] of keys.entries()) parseKey(k, where, `${at}.events[${i}]`, 'an event key is an object of `{ t, name, … }`');
448
+ for (const [i, k] of keys.entries()) {
449
+ parseKey(k, where, `${at}.events[${i}]`, 'an event key is an object of `{ t, name, … }`', 'MotionEventKey');
450
+ }
330
451
  }
331
452
 
332
453
  /**
@@ -343,7 +464,7 @@ function parseDrawOrder(raw: unknown, where: string, at: string): void {
343
464
  const keys = needArray(raw, where, `${at}.drawOrder`, 'it is an array of `{ t, offsets? }` keys — one timeline per animation, naming no target');
344
465
  for (const [i, entry] of keys.entries()) {
345
466
  const key = `${at}.drawOrder[${i}]`;
346
- const dk = parseKey(entry, where, key, 'a draw-order key is an object of `{ t, offsets? }`');
467
+ const dk = parseKey(entry, where, key, 'a draw-order key is an object of `{ t, offsets? }`', 'MotionDrawOrderKey');
347
468
  if (dk.offsets === undefined) continue;
348
469
  const offsets = needArray(
349
470
  dk.offsets,
@@ -354,6 +475,7 @@ function parseDrawOrder(raw: unknown, where: string, at: string): void {
354
475
  for (const [j, o] of offsets.entries()) {
355
476
  const oat = `${key}.offsets[${j}]`;
356
477
  const off = needObj(o, where, oat, 'one moved slot is `{ "slot": "<name>", "offset": <places later> }`');
478
+ known(off, 'MotionDrawOrderOffset', where, oat);
357
479
  needString(off.slot, where, `${oat}.slot`, 'it names the slot this key moves');
358
480
  // The TYPE only. Whether it is a whole number, and whether it lands inside
359
481
  // the emitted slots array, are `compile`'s — both need the slot table.
@@ -365,6 +487,7 @@ function parseDrawOrder(raw: unknown, where: string, at: string): void {
365
487
  function parseAnimation(raw: unknown, where: string, name: string): void {
366
488
  const at = `animations."${name}"`;
367
489
  const anim = needObj(raw, where, at, 'an animation is an object of `{ duration, tracks, … }`');
490
+ known(anim, 'MotionAnimation', where, at);
368
491
  const duration = needFinite(anim.duration, where, `${at}.duration`, 'an animation declares its duration in seconds, as a finite number — it is checked against the compiled last key (rule R7), and a comparison against a non-number is silently false');
369
492
  if (duration < 0) {
370
493
  refuse(where, `${at}.duration`, duration, 'a duration is a length of time, so it is not negative');
@@ -398,6 +521,11 @@ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
398
521
  if (raw.spec !== MOTION_SPEC_VERSION) {
399
522
  throw new CompileError(`${where}: unknown motion spec version: ${String(raw.spec)}, expected "${MOTION_SPEC_VERSION}"`);
400
523
  }
524
+ // Before the field checks, for the reason `parseRigSpec` puts its own first: a
525
+ // key nothing reads is often the CAUSE of the field that is missing, and
526
+ // `"animation"` for `"animations"` should be named as the typo it is rather
527
+ // than as an absent table.
528
+ known(raw, 'MotionSpec', where, 'this motion spec');
401
529
  needString(raw.archetype, where, 'archetype', "it names the rig this spec was authored against, and must equal that rig spec's own `name`");
402
530
  needString(raw.cut, where, 'cut', 'it names the cut these keys were authored for');
403
531
  optString(raw.note, where, 'note', 'it is prose for a reader');