rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/motion.ts ADDED
@@ -0,0 +1,809 @@
1
+ /**
2
+ * Reading a motion spec — the parse the motion spec did not have.
3
+ *
4
+ * The rig spec has had `parseRigSpec` since it stopped being three hard-coded
5
+ * tables; the motion spec reached `compile` as `readJson<MotionSpec>(path)`, a
6
+ * CAST, so its declared type said what a correct file holds and not what the one
7
+ * on disk does. Issue #307. The compiler's own comment named the consequence out
8
+ * loud, at the one field that had since grown a guard of its own (#293/#303):
9
+ * `setup: { "lid_l": "plate" }` — the attachment name written where its wrapper
10
+ * belongs — compiled **green** and hid the slot, which is the opposite of what
11
+ * was asked and is stated nowhere. That guard is now here, and it now covers
12
+ * every key in the table rather than the ones the emit loop happened to reach.
13
+ *
14
+ * ## What lives here, and what stays in `compile`
15
+ *
16
+ * The split is **shape versus meaning**, and it is a split about what each layer
17
+ * can see rather than a ranking of the checks:
18
+ *
19
+ * - **here** — is this a number, a string, an array, an object; is a required
20
+ * field present; is a structure the structure the format describes. Answerable
21
+ * from the motion file ALONE, which is why it can run at load and why the
22
+ * rest of the compiler is allowed to assume its inputs from then on.
23
+ * - **in `compile`** — does this name resolve against the rig spec, is this bone
24
+ * in that group, does this key's value have the right number of channels for
25
+ * its property, does this `derive` have a projection onto this axis, is the
26
+ * last key allowed to carry an easing. Every one of those needs something the
27
+ * motion file does not contain: the rig, the property table, or the key's
28
+ * position in its own track.
29
+ *
30
+ * 🚨 **The failure this split is drawn to avoid is two layers refusing one thing
31
+ * under two names**, which makes the error output contradict itself. So a guard
32
+ * the parser makes unreachable was DELETED from `compile` rather than left as a
33
+ * second opinion — the version tag, the `setup` entry shape, the three
34
+ * `non-finite time` guards, the `ik`/`transform`/`deform` array-and-name guards.
35
+ * Where a compile guard is still reachable it stays: `checkKeyTime` still catches
36
+ * a key genuinely past its duration, `rgbaHex` still counts the channels of an
37
+ * `rgba` track key, and the group `v`-map / `derive` refusals (#320,
38
+ * `src/trackgen.ts`) are untouched — every one of them reads the group's member
39
+ * list or the property's projection table, neither of which is in this file.
40
+ *
41
+ * ## Unknown keys ARE refused — issue #545
42
+ *
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.
55
+ */
56
+ import { CompileError } from './errors.ts';
57
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
58
+ import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
59
+ import { SEQUENCE_MODES } from './timelines.ts';
60
+ import type { MotionSpec } from './types.ts';
61
+
62
+ export const MOTION_SPEC_VERSION = 'rigc-motion/1';
63
+
64
+ /**
65
+ * A track's `physics` target for the timeline that names NO constraint (issue
66
+ * #726) — the one the runtime applies to every physics constraint whose own data
67
+ * declares the keyed property global (`"strengthGlobal": true` for `strength`,
68
+ * and so on; `reset` resets every physics constraint and asks no flag).
69
+ * `compile` emits it under the empty name, which is the skeleton file's own
70
+ * spelling of it (`SkeletonJson.js:1048-1054`).
71
+ *
72
+ * 🔑 Not the empty string itself, and that is the choice rather than a detail:
73
+ * `""` is the likeliest shape of a value somebody forgot to fill in, and a
74
+ * forgotten target that silently became "every global constraint" is the exact
75
+ * silence this format exists to name. So `"physics": ""` is refused by name and
76
+ * points here, and `"*"` is reserved the other way round: `compile` refuses a
77
+ * physics constraint that is CALLED `"*"`, because a track naming it could not
78
+ * say which of the two it meant.
79
+ */
80
+ export const EVERY_GLOBAL_PHYSICS = '*';
81
+
82
+ /**
83
+ * The six fields that pick a track's target family. Listed here as well as in
84
+ * `compile`'s `resolveTargets` because the two ask different questions of it:
85
+ * this one asks whether each is a string, that one asks whether exactly one is
86
+ * present and what it resolves to.
87
+ */
88
+ const TARGET_FIELDS = ['slot', 'group', 'bone', 'physics', 'path', 'slider'] as const;
89
+
90
+ /** Every numeric field of a `physics` table entry — `bone` and `note` are not numbers. */
91
+ const PHYSICS_NUMBERS = [
92
+ 'x',
93
+ 'y',
94
+ 'rotate',
95
+ 'scaleX',
96
+ 'shearX',
97
+ 'inertia',
98
+ 'strength',
99
+ 'damping',
100
+ 'mass',
101
+ 'wind',
102
+ 'gravity',
103
+ 'mix',
104
+ 'fps',
105
+ 'limit',
106
+ ] as const;
107
+
108
+ /** The two animation-level constraint families, which share one entry shape. */
109
+ const CONSTRAINT_GROUPS = ['ik', 'transform'] as const;
110
+
111
+ /**
112
+ * Every key each shape of this format owns, keyed by the interface that declares
113
+ * it — the runtime shadow of types TypeScript erases, and the other half of
114
+ * `RIG_KEYS` in [`rig.ts`](rig.ts).
115
+ *
116
+ * 🔒 Held to those interfaces by `CUR17` in `selftest.ts`, which reads the
117
+ * declaring source and compares. The interfaces are spread over three modules —
118
+ * `types.ts` for the format, [`trackgen.ts`](trackgen.ts) for a track's `derive`
119
+ * and [`deformgen.ts`](deformgen.ts) for a deform key's `transform` — and the
120
+ * table is one table anyway, because what it describes is one file.
121
+ *
122
+ * ⚠️ `MotionKey.v` is deliberately not a shape here. On a group track it is a
123
+ * `MotionMemberValues` map keyed by **member name**, so every key of it is a
124
+ * name from the rig rather than a field of this format; `resolveMemberTrack`
125
+ * refuses a member the group does not have, which is the check that fits.
126
+ */
127
+ export const MOTION_KEYS = {
128
+ MotionSpec: ['spec', 'archetype', 'cut', 'note', 'easings', 'groups', 'setup', 'physics', 'animations', 'mix'],
129
+ MotionMix: ['default', 'pairs'],
130
+ MotionSetupSlot: ['attachment', 'color'],
131
+ MotionPhysics: [
132
+ 'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity',
133
+ 'mix', 'fps', 'limit', 'note',
134
+ ],
135
+ MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'sequence', 'drawOrder', 'events'],
136
+ MotionTrack: ['slot', 'group', 'bone', 'physics', 'path', 'slider', 'property', 'lag', 'stagger', 'keys'],
137
+ MotionKey: ['t', 'v', 'derive', 'ease', 'curve'],
138
+ MotionIkTrack: ['constraint', 'keys'],
139
+ MotionIkKey: ['t', 'mix', 'softness', 'bendPositive', 'compress', 'stretch', 'ease', 'curve'],
140
+ MotionTransformTrack: ['constraint', 'keys'],
141
+ MotionTransformKey: ['t', 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY', 'ease', 'curve'],
142
+ MotionDeformTrack: ['skin', 'slot', 'attachment', 'keys'],
143
+ MotionDeformKey: ['t', 'offset', 'fromVertex', 'vertices', 'transform', 'ease', 'curve'],
144
+ MotionSequenceTrack: ['skin', 'slot', 'attachment', 'keys'],
145
+ MotionSequenceKey: ['t', 'mode', 'index', 'delay'],
146
+ MotionDrawOrderKey: ['t', 'offsets'],
147
+ MotionDrawOrderOffset: ['slot', 'offset'],
148
+ MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
149
+ TrackDeriveTurn: ['kind', 'degrees', 'depth', 'carried', 'about'],
150
+ DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about'],
151
+ DeformAffine: ['kind', 'scale', 'about'],
152
+ DeformWave: ['kind', 'amplitude', 'wavelength', 'phase', 'along', 'axis'],
153
+ DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis'],
154
+ } as const satisfies Record<string, readonly string[]>;
155
+
156
+ /**
157
+ * The type every key of `MOTION_KEYS` holds, shape by shape — what
158
+ * `refuseValuesOfTheWrongType` refuses a value against (issue #890), held to
159
+ * the key table by `satisfies MotionTypeTable` and by the selftest, exactly as
160
+ * `RIG_TYPES` is.
161
+ *
162
+ * ⚠️ The walk runs LAST in `parseMotionSpec`, after every field check above,
163
+ * so a field this file already checks keeps its own sentence (a physics tuning
164
+ * number, a key's `t`, an easing's handles) and the walk is what covers the
165
+ * fields nothing here reads — an ik key's `mix`, a deform key's `offset`, an
166
+ * event key's `float`, a derive's `degrees`.
167
+ *
168
+ * Where an unchecked type is refused: an `enum` row — the entry `MOTION_ENUMS`
169
+ * has for it, below, which is a table a control reads rather than a list here;
170
+ * `MotionKey.v`, a key's `curve`, a derive's `depth` and `mix.pairs` (`mixed`)
171
+ * — the track compiler by property, the curve reader, the derive evaluator and
172
+ * `parseMix`. A sequence key's `mode` is a closed set too, but its interface
173
+ * declares it `string` and so does this row: `parseSequence` refuses a name
174
+ * outside `SEQUENCE_MODES` before the walk runs, and a non-string with it.
175
+ */
176
+ type MotionTypeTable = {
177
+ readonly [S in keyof typeof MOTION_KEYS]: { readonly [K in (typeof MOTION_KEYS)[S][number]]: SpecValueType };
178
+ };
179
+
180
+ export const MOTION_TYPES = {
181
+ MotionSpec: {
182
+ spec: 'enum', archetype: 'string', cut: 'string', note: 'string', easings: 'map of number[]', groups: 'map of string[]',
183
+ setup: 'map of object', physics: 'map of object', animations: 'map of object', mix: 'object',
184
+ },
185
+ MotionMix: { default: 'number', pairs: 'mixed' },
186
+ MotionSetupSlot: { attachment: 'string | null', color: 'number[]' },
187
+ MotionPhysics: {
188
+ bone: 'string', x: 'number', y: 'number', rotate: 'number', scaleX: 'number', shearX: 'number', inertia: 'number',
189
+ strength: 'number', damping: 'number', mass: 'number', wind: 'number', gravity: 'number', mix: 'number',
190
+ fps: 'number', limit: 'number', note: 'string',
191
+ },
192
+ MotionAnimation: {
193
+ duration: 'number', loop: 'boolean', note: 'string', tracks: 'object[]', ik: 'object[]', transform: 'object[]',
194
+ deform: 'object[]', sequence: 'object[]', drawOrder: 'object[]', events: 'object[]',
195
+ },
196
+ MotionTrack: {
197
+ slot: 'string', group: 'string', bone: 'string', physics: 'string', path: 'string', slider: 'string',
198
+ property: 'enum', lag: 'number', stagger: 'number', keys: 'object[]',
199
+ },
200
+ MotionKey: { t: 'number', v: 'mixed', derive: 'object', ease: 'string', curve: 'mixed' },
201
+ MotionIkTrack: { constraint: 'string', keys: 'object[]' },
202
+ MotionIkKey: {
203
+ t: 'number', mix: 'number', softness: 'number', bendPositive: 'boolean', compress: 'boolean', stretch: 'boolean',
204
+ ease: 'string', curve: 'mixed',
205
+ },
206
+ MotionTransformTrack: { constraint: 'string', keys: 'object[]' },
207
+ MotionTransformKey: {
208
+ t: 'number', mixRotate: 'number', mixX: 'number', mixY: 'number', mixScaleX: 'number', mixScaleY: 'number',
209
+ mixShearY: 'number', ease: 'string', curve: 'mixed',
210
+ },
211
+ MotionDeformTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
212
+ MotionDeformKey: {
213
+ t: 'number', offset: 'number', fromVertex: 'number', vertices: 'number[] | null', transform: 'object', ease: 'string', curve: 'mixed',
214
+ },
215
+ MotionSequenceTrack: { skin: 'string', slot: 'string', attachment: 'string', keys: 'object[]' },
216
+ MotionSequenceKey: { t: 'number', mode: 'string', index: 'number', delay: 'number' },
217
+ MotionDrawOrderKey: { t: 'number', offsets: 'object[]' },
218
+ MotionDrawOrderOffset: { slot: 'string', offset: 'number' },
219
+ MotionEventKey: {
220
+ t: 'number', name: 'string', int: 'number', float: 'number', string: 'string', volume: 'number', balance: 'number',
221
+ },
222
+ TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number' },
223
+ DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number' },
224
+ DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]' },
225
+ DeformWave: { kind: 'enum', amplitude: 'number', wavelength: 'number', phase: 'number', along: 'enum', axis: 'enum' },
226
+ DeformBend: { kind: 'enum', amount: 'number', from: 'number', to: 'number', power: 'number', along: 'enum', axis: 'enum' },
227
+ } as const satisfies MotionTypeTable;
228
+
229
+ /**
230
+ * Who refuses each `enum` row of `MOTION_TYPES` outside its set (issue #900);
231
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what an entry means. Every one
232
+ * is an owner: measured when this table was written, a planted `5` and `"foo"`
233
+ * on each row was refused by name by the reader named here, listing the names
234
+ * that exist, so none of them states a set. A deform transform's and a derive's
235
+ * `kind` choose the row they are checked against, as a generator's does.
236
+ */
237
+ export const MOTION_ENUMS = {
238
+ MotionSpec: { spec: { owner: 'parseMotionSpecInto' } },
239
+ MotionTrack: { property: { owner: 'compileTrack' } },
240
+ TrackDeriveTurn: { kind: { owner: 'evaluateTrackDerive' } },
241
+ DeformTurn: { kind: { owner: 'evaluateDeformTransform' } },
242
+ DeformAffine: { kind: { owner: 'evaluateDeformTransform' } },
243
+ DeformWave: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
244
+ DeformBend: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
245
+ } as const satisfies SpecEnumTable<typeof MOTION_TYPES>;
246
+
247
+ /** A deform key's `transform` kinds, and the shape each one's keys come from. */
248
+ const DEFORM_TRANSFORM_SHAPE: Record<string, keyof typeof MOTION_KEYS> = {
249
+ yaw: 'DeformTurn',
250
+ pitch: 'DeformTurn',
251
+ affine: 'DeformAffine',
252
+ wave: 'DeformWave',
253
+ bend: 'DeformBend',
254
+ };
255
+
256
+ function isObj(v: unknown): v is Record<string, unknown> {
257
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
258
+ }
259
+
260
+ /**
261
+ * What a value actually IS, for a refusal to name.
262
+ *
263
+ * The generalisation of `describeSetupEntry`, whose wording it keeps verbatim for
264
+ * the three shapes that one covered — those exact strings are what issue #293's
265
+ * refusal reads like and what the selftest pins.
266
+ */
267
+ function describe(v: unknown): string {
268
+ if (v === undefined) return 'absent';
269
+ if (v === null) return 'null';
270
+ if (Array.isArray(v)) return `an array of ${v.length}`;
271
+ if (typeof v === 'string') return `the string ${JSON.stringify(v)}`;
272
+ if (typeof v === 'number' || typeof v === 'boolean') return `${String(v)}`;
273
+ // "a object" is what `describeSetupEntry` printed; the article is worth a line
274
+ // because these messages are read far more often than they are written.
275
+ return `${typeof v === 'object' ? 'an' : 'a'} ${typeof v}`;
276
+ }
277
+
278
+ /**
279
+ * The one refusal shape in this file: **file, key, what it actually is, and the
280
+ * spelling that works.** Every message below is built from it, so a reader who
281
+ * has seen one has seen the format.
282
+ */
283
+ function refuse(where: string, key: string, is: unknown, hint: string): never {
284
+ throw new CompileError(`${where}: \`${key}\` is ${describe(is)}; ${hint}`);
285
+ }
286
+
287
+ /**
288
+ * The second refusal shape: **a key this format does not have**, from the one
289
+ * implementation of it. `at` is the path the messages above already print, so a
290
+ * reader meets `setup."lid_l"` whether the entry was the wrong type or carried
291
+ * the wrong field.
292
+ */
293
+ function known(node: unknown, shape: keyof typeof MOTION_KEYS, where: string, at: string): void {
294
+ if (!isObj(node)) return;
295
+ refuseUnknownKeys(node, MOTION_KEYS[shape], where, `\`${at}\``);
296
+ // The root is named by its keys alone — `archetype`, not `this motion spec.archetype`.
297
+ const prefix = shape === 'MotionSpec' ? '' : at;
298
+ visiting?.push({
299
+ node,
300
+ shape,
301
+ name: (tail) => `\`${prefix}${tail.map((step, i) => (typeof step === 'number' ? `[${step}]` : prefix === '' && i === 0 ? step : `.${step}`)).join('')}\``,
302
+ });
303
+ }
304
+
305
+ /**
306
+ * The nodes `known` admitted during the parse in progress, in the order it
307
+ * admitted them — what `refuseValuesOfTheWrongType` walks at the end of
308
+ * `parseMotionSpec`. Module state rather than a parameter because `known` is
309
+ * called from a dozen readers that would otherwise each carry it; it is set and
310
+ * cleared by `parseMotionSpec` alone, around a parse that is synchronous and
311
+ * does not re-enter, so no two parses ever share it.
312
+ */
313
+ let visiting: ShapeVisit[] | null = null;
314
+
315
+ // --- the leaf checks, each returning the value it just proved ---------------
316
+
317
+ function needObj(v: unknown, where: string, key: string, hint: string): Record<string, unknown> {
318
+ if (!isObj(v)) refuse(where, key, v, hint);
319
+ return v;
320
+ }
321
+
322
+ function needArray(v: unknown, where: string, key: string, hint: string): unknown[] {
323
+ if (!Array.isArray(v)) refuse(where, key, v, hint);
324
+ return v;
325
+ }
326
+
327
+ function needString(v: unknown, where: string, key: string, hint: string): string {
328
+ if (typeof v !== 'string' || v.length === 0) refuse(where, key, v, hint);
329
+ return v;
330
+ }
331
+
332
+ function optString(v: unknown, where: string, key: string, hint: string): void {
333
+ if (v !== undefined && typeof v !== 'string') refuse(where, key, v, hint);
334
+ }
335
+
336
+ function needFinite(v: unknown, where: string, key: string, hint: string): number {
337
+ if (typeof v !== 'number' || !Number.isFinite(v)) refuse(where, key, v, hint);
338
+ return v;
339
+ }
340
+
341
+ function optFinite(v: unknown, where: string, key: string, hint: string): void {
342
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) refuse(where, key, v, hint);
343
+ }
344
+
345
+ /**
346
+ * A named easing's handles: four finite numbers, and nothing else.
347
+ *
348
+ * ⭐ The most silent field in the format before this parse existed. `easings` is
349
+ * read only through `bezierForChannel`, which destructures four values with no
350
+ * guard at all — so `[0.42, 0, 0.58]` emitted `"curve": [0.42, 0, 0.58, null]`
351
+ * into the artifact, and a `"x"` in one slot emitted a `NaN` the round trip
352
+ * turns into `null` too. Neither is a curve, both loaded, and nothing said so.
353
+ */
354
+ function parseEasings(raw: unknown, where: string): void {
355
+ const easings = needObj(raw, where, 'easings', 'it is a table of named handles, `{ "<name>": [hx1, hy1, hx2, hy2] }` (write `{}` if this spec names none)');
356
+ for (const [name, handles] of Object.entries(easings)) {
357
+ const key = `easings."${name}"`;
358
+ const hint =
359
+ 'a named easing is FOUR finite numbers — the graph-view handles [hx1, hy1, hx2, hy2]. ' +
360
+ 'Nothing downstream counts them, so a short or non-numeric array reaches the artifact as a curve with a `null` in it';
361
+ const arr = needArray(handles, where, key, hint);
362
+ if (arr.length !== 4) refuse(where, key, arr, hint);
363
+ for (const [i, n] of arr.entries()) needFinite(n, where, `${key}[${i}]`, hint);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * `setup` — the entry-shape guard of #293/#303, moved here and widened.
369
+ *
370
+ * 🚨 The guard used to live in the emit loop, which walks the RIG's slots and
371
+ * `continue`s past a slot with no attachments before it ever reads `setup`. So
372
+ * two corners of the very shape it was written for stayed green: a `setup` entry
373
+ * for a slot the rig declares without attachments, and one for a slot the rig
374
+ * does not declare at all. Both are the reader's most likely spelling of the
375
+ * mistake — you write the entry, and the slot it names is exactly the one you
376
+ * have not finished wiring up. Parsing the table on its own terms has no such
377
+ * blind spot: every key is checked, and whether the rig knows the slot is a
378
+ * separate question `compile` still asks.
379
+ */
380
+ function parseSetup(raw: unknown, where: string): void {
381
+ if (raw === undefined) return;
382
+ const setup = needObj(raw, where, 'setup', 'it is a table keyed by slot name, `{ "<slot>": { "attachment": … } }`');
383
+ for (const [slot, entry] of Object.entries(setup)) {
384
+ const key = `setup."${slot}"`;
385
+ if (!isObj(entry)) {
386
+ refuse(
387
+ where,
388
+ key,
389
+ entry,
390
+ 'a setup entry is an object of `{ attachment?: string | null, color?: [r, g, b, a] }` — to show nothing ' +
391
+ `there write \`"${slot}": { "attachment": null }\`, and to show an attachment write ` +
392
+ `\`"${slot}": { "attachment": "<name>" }\``,
393
+ );
394
+ }
395
+ if (entry.attachment !== undefined && entry.attachment !== null && typeof entry.attachment !== 'string') {
396
+ refuse(where, `${key}.attachment`, entry.attachment, 'it is an attachment name, or null for "show nothing"');
397
+ }
398
+ known(entry, 'MotionSetupSlot', where, key);
399
+ if (entry.color !== undefined) {
400
+ 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"';
401
+ const color = needArray(entry.color, where, `${key}.color`, hint);
402
+ if (color.length !== 4) refuse(where, `${key}.color`, color, hint);
403
+ for (const [i, n] of color.entries()) {
404
+ const at = `${key}.color[${i}]`;
405
+ needFinite(n, where, at, hint);
406
+ if ((n as number) < 0 || (n as number) > 1) refuse(where, at, n, hint);
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * `physics` — the tuning table.
414
+ *
415
+ * Every field but `bone` and `note` goes straight into `r6`, which is NaN in and
416
+ * NaN out, and the emitter writes that NaN as `null`: `"mass": "heavy"` shipped
417
+ * `"mass": null` in the constraint, which the runtime reads as zero mass. A
418
+ * constraint with zero mass is assertion A23's own example of one that never
419
+ * settles, and it arrived without a word from either layer.
420
+ */
421
+ function parsePhysics(raw: unknown, where: string): void {
422
+ if (raw === undefined) return;
423
+ const table = needObj(raw, where, 'physics', 'it is a table keyed by constraint name, `{ "<name>": { "bone": … } }`');
424
+ for (const [name, entry] of Object.entries(table)) {
425
+ const key = `physics."${name}"`;
426
+ if (name === EVERY_GLOBAL_PHYSICS) {
427
+ throw new CompileError(
428
+ `${where}: \`${key}\` names a physics constraint "${EVERY_GLOBAL_PHYSICS}", and that name is reserved: a ` +
429
+ `track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives ` +
430
+ 'every one declaring the keyed property global, so a constraint called ' +
431
+ `"${EVERY_GLOBAL_PHYSICS}" could not be keyed by name. Give it another name`,
432
+ );
433
+ }
434
+ const spec = needObj(entry, where, key,'a physics constraint is an object naming the bone it drives and the components it drives it in');
435
+ known(spec, 'MotionPhysics', where, key);
436
+ needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
437
+ for (const field of PHYSICS_NUMBERS) {
438
+ 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');
439
+ }
440
+ optString(spec.note, where, `${key}.note`, 'it is prose for a reader');
441
+ }
442
+ }
443
+
444
+ /**
445
+ * `mix` — the player-side `AnimationStateData` config.
446
+ *
447
+ * Not emitted into skeleton JSON, which is why nothing had ever looked at it:
448
+ * `{ "default": "fast" }` passed the compiler, the gate and the round trip, and
449
+ * became a `NaN` mix duration in whatever player read the spec.
450
+ */
451
+ function parseMix(raw: unknown, where: string): void {
452
+ if (raw === undefined) return;
453
+ const mix = needObj(raw, where, 'mix', 'it is `{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`');
454
+ known(mix, 'MotionMix', where, 'mix');
455
+ needFinite(mix.default, where, 'mix.default', 'the default mix duration is a finite number of seconds');
456
+ if (mix.pairs === undefined) return;
457
+ const pairs = needArray(mix.pairs, where, 'mix.pairs', 'it is an array of `["<from>", "<to>", <seconds>]` triples');
458
+ for (const [i, pair] of pairs.entries()) {
459
+ const key = `mix.pairs[${i}]`;
460
+ const hint = 'a mix pair is `["<from animation>", "<to animation>", <seconds>]` — three entries, two names and a duration';
461
+ const triple = needArray(pair, where, key, hint);
462
+ if (triple.length !== 3) refuse(where, key, triple, hint);
463
+ needString(triple[0], where, `${key}[0]`, hint);
464
+ needString(triple[1], where, `${key}[1]`, hint);
465
+ needFinite(triple[2], where, `${key}[2]`, hint);
466
+ }
467
+ }
468
+
469
+ /**
470
+ * A key's `t`, for every key family there is.
471
+ *
472
+ * ⭐ One owner for one question. `events`, `ik`/`transform` and `deform` each
473
+ * grew their own `has a non-finite time` guard as they were added and the three
474
+ * families that came first — value tracks, slot tracks, `drawOrder` — never got
475
+ * one, so `{ "t": "0" }` on a `rotate` track reached the emitted JSON as a `NaN`
476
+ * time. The three guards in `compile` are gone: they can no longer fire.
477
+ */
478
+ function parseKeyTime(key: Record<string, unknown>, where: string, at: string): void {
479
+ needFinite(key.t, where, `${at}.t`, 'a key states its time in seconds, as a finite number');
480
+ }
481
+
482
+ function parseKeyEasing(key: Record<string, unknown>, where: string, at: string): void {
483
+ optString(key.ease, where, `${at}.ease`, 'it names an entry of this spec\'s `easings` table, or is "stepped"');
484
+ }
485
+
486
+ /**
487
+ * One `{ t, … }` key of any family: an object, with a finite time and a string
488
+ * `ease`.
489
+ *
490
+ * `shape` is per caller because the five families' key shapes are five
491
+ * different sets — an `rgba` key's `v` is not a thing an ik key may carry, and
492
+ * an ik key's `softness` is not a thing a value track may. One shared shape here
493
+ * would accept every field of every family on all of them, which is a key set
494
+ * nothing in the format actually has.
495
+ */
496
+ function parseKey(
497
+ raw: unknown,
498
+ where: string,
499
+ at: string,
500
+ hint: string,
501
+ shape: keyof typeof MOTION_KEYS,
502
+ ): Record<string, unknown> {
503
+ const key = needObj(raw, where, at, hint);
504
+ known(key, shape, where, at);
505
+ parseKeyTime(key, where, at);
506
+ parseKeyEasing(key, where, at);
507
+ return key;
508
+ }
509
+
510
+ function parseTracks(raw: unknown, where: string, at: string): void {
511
+ const tracks = needArray(raw, where, `${at}.tracks`, 'it is an array of `{ <target>, property, keys }` tracks (write `[]` for an animation whose timelines are all in the families beside it)');
512
+ for (const [i, entry] of tracks.entries()) {
513
+ const key = `${at}.tracks[${i}]`;
514
+ const track = needObj(entry, where, key, 'a track is an object naming one target, one property and its keys');
515
+ known(track, 'MotionTrack', where, key);
516
+ needString(track.property, where, `${key}.property`, 'a track states the property it keys — the table is AUTHORING §4.4');
517
+ for (const field of TARGET_FIELDS) {
518
+ optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
519
+ }
520
+ if (track.physics === '') {
521
+ refuse(
522
+ where,
523
+ `${key}.physics`,
524
+ track.physics,
525
+ 'the empty name is how a skeleton file spells a physics timeline that names no constraint, and a motion ' +
526
+ `spec spells that "${EVERY_GLOBAL_PHYSICS}" — it drives every physics constraint that declares the keyed ` +
527
+ 'property global (`"strengthGlobal": true` for `strength`). Name one constraint, or write ' +
528
+ `"${EVERY_GLOBAL_PHYSICS}"`,
529
+ );
530
+ }
531
+ 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');
532
+ optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
533
+ const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
534
+ for (const [j, k] of keys.entries()) {
535
+ const at = `${key}.keys[${j}]`;
536
+ const parsed = parseKey(k, where, at, 'a key is an object of `{ t, v, … }`', 'MotionKey');
537
+ // `derive` states a generator's parameters rather than a value, and its
538
+ // shape lives with the evaluator (`src/trackgen.ts`). `yaw` and `pitch`
539
+ // are one interface — they differ in which coordinate they read, not in
540
+ // what they carry — so there is no dispatch to do here, and whether the
541
+ // kind is one of the two stays `evaluateTrackDerive`'s refusal.
542
+ known(parsed.derive, 'TrackDeriveTurn', where, `${at}.derive`);
543
+ }
544
+ }
545
+ }
546
+
547
+ function parseConstraintTracks(raw: unknown, where: string, at: string, group: (typeof CONSTRAINT_GROUPS)[number]): void {
548
+ if (raw === undefined) return;
549
+ const entries = needArray(raw, where, `${at}.${group}`, `it is an array of \`{ "constraint": "<name>", "keys": [...] }\` entries — one per ${group} constraint`);
550
+ for (const [i, entry] of entries.entries()) {
551
+ const key = `${at}.${group}[${i}]`;
552
+ const track = needObj(entry, where, key, `${group === 'ik' ? 'an ik' : 'a transform'} timeline is an object of \`{ constraint, keys }\``);
553
+ known(track, group === 'ik' ? 'MotionIkTrack' : 'MotionTransformTrack', where, key);
554
+ needString(track.constraint, where, `${key}.constraint`, `4.3 writes this group as \`${group}.<constraint>\`, so the constraint name is the only target there is`);
555
+ const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of keys, each naming the same set of mix fields');
556
+ for (const [j, k] of keys.entries()) {
557
+ parseKey(
558
+ k,
559
+ where,
560
+ `${key}.keys[${j}]`,
561
+ `a ${group} key is an object of \`{ t, … }\``,
562
+ group === 'ik' ? 'MotionIkKey' : 'MotionTransformKey',
563
+ );
564
+ }
565
+ }
566
+ }
567
+
568
+ function parseDeform(raw: unknown, where: string, at: string): void {
569
+ if (raw === undefined) return;
570
+ const entries = needArray(raw, where, `${at}.deform`, 'it is an array of `{ slot, attachment, keys }` entries — one per skin/slot/attachment triple');
571
+ for (const [i, entry] of entries.entries()) {
572
+ const key = `${at}.deform[${i}]`;
573
+ const track = needObj(entry, where, key, 'a deform timeline is an object of `{ skin?, slot, attachment, keys }`');
574
+ known(track, 'MotionDeformTrack', where, key);
575
+ optString(track.skin, where, `${key}.skin`, 'it names the skin the attachment lives in; absent means "default"');
576
+ needString(track.slot, where, `${key}.slot`, 'a deform timeline keys one attachment of one slot, named here');
577
+ needString(track.attachment, where, `${key}.attachment`, "it is the attachment's placeholder name inside that skin and slot");
578
+ const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of keys, each a sparse edit of the setup geometry');
579
+ for (const [j, k] of keys.entries()) {
580
+ const at = `${key}.keys[${j}]`;
581
+ const parsed = parseKey(k, where, at, 'a deform key is an object of `{ t, vertices? | transform? }`', 'MotionDeformKey');
582
+ // `transform` is five kinds sharing one field name, and they share almost
583
+ // nothing else: `wave` carries `wavelength` and `bend` carries `power`, so
584
+ // checking either against the union's flattened keys would accept both on
585
+ // both. An unrecognised `kind` is `evaluateDeformTransform`'s refusal,
586
+ // which names the five.
587
+ if (isObj(parsed.transform)) {
588
+ const shape = DEFORM_TRANSFORM_SHAPE[String(parsed.transform.kind)];
589
+ if (shape !== undefined) known(parsed.transform, shape, where, `${at}.transform`);
590
+ }
591
+ }
592
+ }
593
+ }
594
+
595
+ /**
596
+ * `sequence` — which frame of an attachment's numbered series shows.
597
+ *
598
+ * Everything decidable from the key alone is decided here; what needs the rig
599
+ * (does the attachment carry a `sequence` block, is `index` inside its `count`)
600
+ * is `compile`'s. Each refusal is a key the parser loads without a word and
601
+ * plays as something else — measured on spine-core 4.3.13 (issue #729):
602
+ *
603
+ * - a `mode` outside the seven — `SequenceMode[name]` is `undefined`, the mode
604
+ * bits store 0, and the key plays as `hold`;
605
+ * - an `index` that is not a whole number of at least 0 — it is stored as
606
+ * `index << 4`, so `1.5` shows frame 1 and a negative one indexes before the
607
+ * series;
608
+ * - a `delay` that is not a number of at least 0 — and, under a mode that
609
+ * advances, an EFFECTIVE delay of 0: the parser carries a key's delay from
610
+ * the key before (`lastDelay`, 0 on the first), `(time - keyTime) / 0` is
611
+ * `Infinity` and `Infinity | 0` is 0, so a `loop` at delay 0 shows its
612
+ * first frame for the whole key.
613
+ */
614
+ function parseSequence(raw: unknown, where: string, at: string): void {
615
+ if (raw === undefined) return;
616
+ const entries = needArray(raw, where, `${at}.sequence`, 'it is an array of `{ slot, attachment, keys }` entries — one per skin/slot/attachment triple');
617
+ for (const [i, entry] of entries.entries()) {
618
+ const key = `${at}.sequence[${i}]`;
619
+ const track = needObj(entry, where, key, 'a sequence timeline is an object of `{ skin?, slot, attachment, keys }`');
620
+ known(track, 'MotionSequenceTrack', where, key);
621
+ optString(track.skin, where, `${key}.skin`, 'it names the skin the attachment lives in; absent means "default"');
622
+ needString(track.slot, where, `${key}.slot`, 'a sequence timeline steps one attachment of one slot, named here');
623
+ needString(track.attachment, where, `${key}.attachment`, "it is the attachment's placeholder name inside that skin and slot");
624
+ const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, mode?, index?, delay? }` keys');
625
+ // The delay the PARSER will read at each key: the stated one, else the
626
+ // previous key's, else 0 (`lastDelay` in `readAnimation`).
627
+ let carried = 0;
628
+ for (const [j, k] of keys.entries()) {
629
+ const kat = `${key}.keys[${j}]`;
630
+ const parsed = parseKey(k, where, kat, 'a sequence key is an object of `{ t, mode?, index?, delay? }`', 'MotionSequenceKey');
631
+ if (parsed.mode !== undefined && !(SEQUENCE_MODES as readonly unknown[]).includes(parsed.mode)) {
632
+ refuse(
633
+ where,
634
+ `${kat}.mode`,
635
+ parsed.mode,
636
+ `a sequence mode is one of the ${SEQUENCE_MODES.length} the format has — ${SEQUENCE_MODES.join(', ')} ` +
637
+ '(absent means "hold"). The parser reads `SequenceMode[mode]`, which is undefined for anything else, and ' +
638
+ 'stores mode bits 0: the key would load without a word and play as "hold"',
639
+ );
640
+ }
641
+ const index = parsed.index;
642
+ if (index !== undefined && (typeof index !== 'number' || !Number.isInteger(index) || index < 0)) {
643
+ refuse(
644
+ where,
645
+ `${kat}.index`,
646
+ index,
647
+ 'it is the 0-based frame this key starts on, so a whole number of at least 0 — the runtime stores it as ' +
648
+ '`index << 4`, which truncates a fraction (1.5 showed frame 1) and puts a negative one before the series',
649
+ );
650
+ }
651
+ const delay = parsed.delay;
652
+ if (delay !== undefined && (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0)) {
653
+ refuse(where, `${kat}.delay`, delay, 'it is the seconds each frame shows for, so a finite number of at least 0');
654
+ }
655
+ if (typeof delay === 'number') carried = delay;
656
+ const mode = typeof parsed.mode === 'string' ? parsed.mode : 'hold';
657
+ if (mode !== 'hold' && carried === 0) {
658
+ throw new CompileError(
659
+ `${where}: \`${kat}\` plays "${mode}" at a delay of 0` +
660
+ (delay === undefined
661
+ ? ` — it states none, and the parser carries the previous key's (${j === 0 ? 'there is none, so 0' : '0'})`
662
+ : '') +
663
+ '. The runtime advances the frame by `(time - keyTime) / delay`, which is Infinity at 0, and ' +
664
+ '`Infinity | 0` is 0 — so the key shows its first frame for as long as it lasts, which is "hold" spelt ' +
665
+ `as "${mode}". State the seconds per frame, or write "hold"`,
666
+ );
667
+ }
668
+ }
669
+ }
670
+ }
671
+
672
+ function parseEvents(raw: unknown, where: string, at: string): void {
673
+ if (raw === undefined) return;
674
+ const keys = needArray(raw, where, `${at}.events`, 'it is an array of `{ t, name }` firings — one timeline per animation, naming no target');
675
+ for (const [i, k] of keys.entries()) {
676
+ parseKey(k, where, `${at}.events[${i}]`, 'an event key is an object of `{ t, name, … }`', 'MotionEventKey');
677
+ }
678
+ }
679
+
680
+ /**
681
+ * `drawOrder`.
682
+ *
683
+ * ⚠️ The quiet one is `offsets`: `readDrawOrder` treats a key with no offsets as
684
+ * "restore the setup order", and `compile` tested that with `!key.offsets?.length`
685
+ * — which is true for `{}` and for a string, so a malformed `offsets` silently
686
+ * became a restore key. That is a complete statement of the draw order made by
687
+ * accident.
688
+ */
689
+ function parseDrawOrder(raw: unknown, where: string, at: string): void {
690
+ if (raw === undefined) return;
691
+ const keys = needArray(raw, where, `${at}.drawOrder`, 'it is an array of `{ t, offsets? }` keys — one timeline per animation, naming no target');
692
+ for (const [i, entry] of keys.entries()) {
693
+ const key = `${at}.drawOrder[${i}]`;
694
+ const dk = parseKey(entry, where, key, 'a draw-order key is an object of `{ t, offsets? }`', 'MotionDrawOrderKey');
695
+ if (dk.offsets === undefined) continue;
696
+ const offsets = needArray(
697
+ dk.offsets,
698
+ where,
699
+ `${key}.offsets`,
700
+ 'it is an array of `{ slot, offset }` moves. Omit the field entirely to restore the setup draw order — a malformed one used to BE that restore key, silently',
701
+ );
702
+ for (const [j, o] of offsets.entries()) {
703
+ const oat = `${key}.offsets[${j}]`;
704
+ const off = needObj(o, where, oat, 'one moved slot is `{ "slot": "<name>", "offset": <places later> }`');
705
+ known(off, 'MotionDrawOrderOffset', where, oat);
706
+ needString(off.slot, where, `${oat}.slot`, 'it names the slot this key moves');
707
+ // The TYPE only. Whether it is a whole number, and whether it lands inside
708
+ // the emitted slots array, are `compile`'s — both need the slot table.
709
+ needFinite(off.offset, where, `${oat}.offset`, 'it is how many places later the slot is drawn, so a number (negative moves it earlier)');
710
+ }
711
+ }
712
+ }
713
+
714
+ function parseAnimation(raw: unknown, where: string, name: string): void {
715
+ const at = `animations."${name}"`;
716
+ const anim = needObj(raw, where, at, 'an animation is an object of `{ duration, tracks, … }`');
717
+ known(anim, 'MotionAnimation', where, at);
718
+ 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');
719
+ if (duration < 0) {
720
+ refuse(where, `${at}.duration`, duration, 'a duration is a length of time, so it is not negative');
721
+ }
722
+ // ⚠️ Optional, and the type used to say otherwise: 20 of the 37 motion specs
723
+ // in this repository declare no `loop` at all. It is a player hint that is not
724
+ // expressible in skeleton JSON, so an absent one costs the artifact nothing —
725
+ // requiring it here would have refused most of the benchmark corpus.
726
+ if (anim.loop !== undefined && typeof anim.loop !== 'boolean') {
727
+ refuse(where, `${at}.loop`, anim.loop, 'it is a player hint, so true or false (absent means the player decides)');
728
+ }
729
+ optString(anim.note, where, `${at}.note`, 'it is prose for a reader');
730
+ parseTracks(anim.tracks, where, at);
731
+ for (const group of CONSTRAINT_GROUPS) parseConstraintTracks(anim[group], where, at, group);
732
+ parseDeform(anim.deform, where, at);
733
+ parseSequence(anim.sequence, where, at);
734
+ parseDrawOrder(anim.drawOrder, where, at);
735
+ parseEvents(anim.events, where, at);
736
+ }
737
+
738
+ /**
739
+ * Parse and check a motion spec, then hand back a typed one.
740
+ *
741
+ * `where` is the file's own path and every message begins with it, for the reason
742
+ * `parseRigSpec` does the same: a reader with two input files and one error has
743
+ * otherwise no way to tell which of them is at fault (issue #227).
744
+ */
745
+ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
746
+ const visits: ShapeVisit[] = [];
747
+ visiting = visits;
748
+ try {
749
+ return parseMotionSpecInto(raw, where, visits);
750
+ } finally {
751
+ visiting = null;
752
+ }
753
+ }
754
+
755
+ function parseMotionSpecInto(raw: unknown, where: string, visits: readonly ShapeVisit[]): MotionSpec {
756
+ if (!isObj(raw)) {
757
+ throw new CompileError(`${where}: a motion spec must be a JSON object, and this file holds ${describe(raw)}`);
758
+ }
759
+ if (raw.spec !== MOTION_SPEC_VERSION) {
760
+ throw new CompileError(`${where}: unknown motion spec version: ${String(raw.spec)}, expected "${MOTION_SPEC_VERSION}"`);
761
+ }
762
+ // Before the field checks, for the reason `parseRigSpec` puts its own first: a
763
+ // key nothing reads is often the CAUSE of the field that is missing, and
764
+ // `"animation"` for `"animations"` should be named as the typo it is rather
765
+ // than as an absent table.
766
+ known(raw, 'MotionSpec', where, 'this motion spec');
767
+ needString(raw.archetype, where, 'archetype', "it names the rig this spec was authored against, and must equal that rig spec's own `name`");
768
+ needString(raw.cut, where, 'cut', 'it names the cut these keys were authored for');
769
+ optString(raw.note, where, 'note', 'it is prose for a reader');
770
+
771
+ parseEasings(raw.easings, where);
772
+ // `groups` — the parser proves the TABLE is a table; `checkMotionGroups` owns
773
+ // each entry's member list, because what it refuses (an empty group, a repeated
774
+ // member) is about what a track naming it would compile, not about JSON shape.
775
+ if (raw.groups !== undefined) {
776
+ needObj(raw.groups, where, 'groups', 'it is a table keyed by group name, `{ "<group>": ["<member>", …] }`');
777
+ }
778
+ parseSetup(raw.setup, where);
779
+ parsePhysics(raw.physics, where);
780
+ parseMix(raw.mix, where);
781
+
782
+ const animations = needObj(raw.animations, where, 'animations', 'it is a table keyed by animation name, `{ "<name>": { "duration": …, "tracks": [...] } }` (write `{}` for a static rig)');
783
+ for (const [name, anim] of Object.entries(animations)) {
784
+ if (name.length === 0) throw new CompileError(`${where}: an animation has an empty name`);
785
+ parseAnimation(anim, where, name);
786
+ }
787
+
788
+ // After every field check above, so a field this file checks keeps its own
789
+ // sentence, and before the finite walk, so a value that is not a number is
790
+ // named as that rather than skipped (issue #890). What reaches this line is a
791
+ // field nothing above reads. Measured before the walk, every one of 303
792
+ // wrong-typed plants over 18 motion shapes was refused somewhere — but later,
793
+ // by the timeline compiler, and 7 of those sentences named the wrong fault:
794
+ // an event key's `"float": "1"` as *float 1 is not finite*, a `"volume": "1"`
795
+ // as an event with no audio, a yaw transform's `"depth": "true"` as a radius
796
+ // that is undefined.
797
+ refuseValuesOfTheWrongType(visits, MOTION_TYPES, where);
798
+ // Every `enum` row here names an owner, so this refuses nothing today; it is
799
+ // called so that a row given a set is refused from the day it is given one.
800
+ refuseValuesOutsideTheirSet(visits, MOTION_TYPES, MOTION_ENUMS, where);
801
+
802
+ // Last, so every field check above keeps its own sentence for a number that
803
+ // is not finite; what reaches this line is a finite double the float32 file
804
+ // cannot carry — a key at 1e308 built green with a `null` in it before issue
805
+ // #881 — and any number in a field no check above reads.
806
+ refuseNumbersTheFileCannotCarry(raw, where, (path) => `\`${dottedPath(path)}\``);
807
+
808
+ return raw as unknown as MotionSpec;
809
+ }