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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A43, the body (issue #1025, cut 4c-3 of step 4c of #380): the two-colour
3
+ * tint loads and poses as written.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * `TwoColourFacts` (`../facts/two_colour.ts`) where it read the skeleton JSON
7
+ * and the loaded skeleton: the dark colour each slot states and the one the
8
+ * runtime loaded, the file's slot timelines, whether an animation exists, and
9
+ * a slot's two colours posed at a key's time — which `validate()` takes from
10
+ * spine-core and the model side from rigc's core. The argument for the rule is
11
+ * above its `check` call, which stays in `validate()`.
12
+ */
13
+ import type { Verdicts } from '../harness.ts';
14
+ import type { TwoColourFacts } from '../facts/two_colour.ts';
15
+ import { SKIP_NO_TWO_COLOR_TINT } from '../reasons.ts';
16
+ import { atStoredKey, isObj } from '../values.ts';
17
+
18
+ export function a43TwoColorTintLoadsAndPosesAsWritten({ fail, skip, stats }: Verdicts, facts: TwoColourFacts): void {
19
+ /** `rrggbb`, or `rrggbbaa` whose last pair the format drops. Null for anything else. */
20
+ const readDarkHex = (hex: string): [number, number, number] | null => {
21
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
22
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
23
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
24
+ };
25
+ /** `rrggbbaa`, or `rrggbb` the runtime opens at alpha 1. Null for anything else. */
26
+ const readLightHex = (hex: string): [number, number, number, number] | null => {
27
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
28
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
29
+ const rgb = [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255);
30
+ return [rgb[0], rgb[1], rgb[2], body.length === 8 ? Number.parseInt(body.slice(6, 8), 16) / 255 : 1];
31
+ };
32
+ /**
33
+ * Half a quantisation step. A channel is one byte in the file and a
34
+ * `Float32Array` entry in a timeline, so the widest honest gap between the
35
+ * number written and the number posed is well under `1/510`.
36
+ */
37
+ const STEP = 1 / 510;
38
+ const off = (found: number, want: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > STEP;
39
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
40
+
41
+ // -- the subjects, read off the FILE ---------------------------------
42
+ const declared = new Map<string, string>();
43
+ for (const { slot, dark } of facts.slotDarks) declared.set(slot, dark);
44
+ // `rgb2` joined in issue #730, and it is this rule's subject rather than a
45
+ // new one's because it is the same tint with the light alpha left out:
46
+ // `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
47
+ // with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
48
+ // does (measured on a forged file: `TypeError: null is not an object
49
+ // (evaluating 'dark.r = …')`). The one thing it does differently is the
50
+ // alpha it does NOT pose, which is `A45`'s question, not this one's.
51
+ const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
52
+ for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
53
+ for (const timeline of ['rgba2', 'rgb2'] as const) {
54
+ const keys = timelines[timeline];
55
+ if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
56
+ }
57
+ }
58
+ if (declared.size === 0 && keyed.length === 0) {
59
+ return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
60
+ }
61
+ stats.darkSlots = declared.size;
62
+ stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
63
+ stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
64
+
65
+ // -- clause 1: the setup pose ----------------------------------------
66
+ for (const [name, hex] of declared) {
67
+ const want = readDarkHex(hex);
68
+ const loaded = facts.loadedDark(name);
69
+ // ⚠️ "The parser kept nothing" is asked FIRST, and the order is the
70
+ // finding rather than a style: an empty string is both unreadable as a
71
+ // colour and dropped outright, and only the second sentence is about
72
+ // what the loaded skeleton holds. Asked the other way round, `""` was
73
+ // reported as "not six hex digits" — true, and about the file, on the
74
+ // one input where the file is not what went wrong.
75
+ if (loaded === null) {
76
+ fail(
77
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
78
+ `slot "${name}" states dark ${JSON.stringify(hex)} and the loaded skeleton holds no dark colour for ` +
79
+ 'it at all — the slot reader takes `dark` through a truthiness test, so a falsy value is dropped ' +
80
+ 'in silence and the slot is tinted with one colour',
81
+ );
82
+ continue;
83
+ }
84
+ if (want === null) {
85
+ fail(
86
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
87
+ `slot "${name}" states dark ${JSON.stringify(hex)}, which is not six hex digits — the parser reads ` +
88
+ 'fixed two-character slices and stores whatever `parseInt` returns, so the loaded colour is ' +
89
+ `(${show([loaded.r, loaded.g, loaded.b])}) rather than a failure`,
90
+ );
91
+ continue;
92
+ }
93
+ const found: [number, number, number] = [loaded.r, loaded.g, loaded.b];
94
+ if (found.some((n, i) => off(n, want[i]))) {
95
+ fail(
96
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
97
+ `slot "${name}" states dark ${JSON.stringify(hex)} and the runtime loaded (${show(found)}), wanted ` +
98
+ `(${show(want)})`,
99
+ );
100
+ }
101
+ }
102
+
103
+ // -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
104
+ //
105
+ // ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
106
+ // throw happens inside `state.apply`, which applies every timeline of the
107
+ // animation at once, so posing a second — correct — `rgba2` timeline in the
108
+ // same animation would take this assertion down with a `threw:` line
109
+ // instead of the two named failures it has already worked out.
110
+ const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
111
+ for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
112
+ const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
113
+ if (!declared.has(slotName)) {
114
+ fail(
115
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
116
+ `${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
117
+ `it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
118
+ `"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
119
+ );
120
+ continue;
121
+ }
122
+ if (cannotPose.has(animName)) continue;
123
+ if (!facts.hasAnimation(animName)) {
124
+ fail('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', `${where}: the loaded skeleton has no animation "${animName}"`);
125
+ continue;
126
+ }
127
+ for (const rawKey of keys) {
128
+ if (!isObj(rawKey)) continue;
129
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
130
+ const wantLight = typeof rawKey.light === 'string' ? readLightHex(rawKey.light) : null;
131
+ const wantDark = typeof rawKey.dark === 'string' ? readDarkHex(rawKey.dark) : null;
132
+ // ⚠️ Posed BEFORE the key's own spelling is judged, so that a key whose
133
+ // hex cannot be read is still reported with the colour the runtime
134
+ // actually holds. `Color.setFromString` slices fixed offsets and stores
135
+ // whatever `parseInt` gives back, so the value found is the product
136
+ // here — "not six hex digits" alone would be the value REQUIRED twice
137
+ // over and the found value nowhere.
138
+ // At the key as the runtime stores it — see `atStoredKey` (#771).
139
+ const posed = facts.posedTint(animName, slotName, atStoredKey(time));
140
+ const light = posed?.light;
141
+ const dark = posed?.dark ?? null;
142
+ if (!light || dark === null) {
143
+ fail(
144
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
145
+ `${where} (t=${time}): the posed skeleton has no ${light ? 'dark colour' : 'slot'} to read`,
146
+ );
147
+ continue;
148
+ }
149
+ const foundLight: [number, number, number, number] = [light.r, light.g, light.b, light.a];
150
+ const foundDark: [number, number, number] = [dark.r, dark.g, dark.b];
151
+ if (wantLight === null || wantDark === null) {
152
+ fail(
153
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
154
+ `${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
155
+ `${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
156
+ `runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
157
+ );
158
+ continue;
159
+ }
160
+ // An `rgb2` key's light colour is three channels, and the posed alpha
161
+ // is not its to state — `RGB2Timeline` leaves it where it was — so the
162
+ // comparison is over the channels the timeline writes. For `rgba2`
163
+ // that is all four, and the line reads as it always did.
164
+ const lightChannels = timeline === 'rgba2' ? 4 : 3;
165
+ if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
166
+ fail(
167
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
168
+ `${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
169
+ `${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
170
+ );
171
+ }
172
+ if (foundDark.some((n, i) => off(n, wantDark[i]))) {
173
+ fail(
174
+ 'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
175
+ `${where} (t=${time}): dark posed (${show(foundDark)}), the key states ${JSON.stringify(rawKey.dark)} ` +
176
+ `= (${show(wantDark)})`,
177
+ );
178
+ }
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A44, the body (issue #1025, step 4c of #380): a linked mesh states no
3
+ * geometry of its own.
4
+ *
5
+ * Moved out of `src/validate.ts` by its subject only. Which links the file
6
+ * declares is the rig's — a document's `linkedmesh` record is one — so the
7
+ * roster moved, and a rig with no link SKIPs on both sides. What A44 refuses
8
+ * is the encoding's: a link record holds no geometry field, and `uvs`,
9
+ * `triangles`, `vertices`, `hull` and `edges` written on a link exist only in
10
+ * the Spine text (the census's row for A44). That clause, and the sentence it
11
+ * prints, stay with the round trip, which hands its finding about each link in
12
+ * as the link's `encoding`, printed in the order the body always printed it.
13
+ */
14
+ import type { Verdicts } from '../harness.ts';
15
+ import type { LinkFacts } from '../facts/linked_meshes.ts';
16
+ import { SKIP_NO_LINKED_MESH } from '../reasons.ts';
17
+
18
+ export function a44LinkedMeshStatesNoGeometryOfItsOwn({ fail, skip }: Verdicts, { links }: LinkFacts): void {
19
+ if (links.length === 0) return skip('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', SKIP_NO_LINKED_MESH);
20
+ for (const link of links) {
21
+ for (const detail of link.encoding) fail('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', detail);
22
+ }
23
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A45, the body (issue #1025, step 4c of #380): the separable colour timelines
3
+ * own their channels and pose as written.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * three facts (`../facts/slot_colour.ts`) where it read the skeleton JSON and
7
+ * the loaded skeleton: the animations' slot timelines in the file's order, as
8
+ * the file spells them; whether an animation exists; and a slot's colour posed
9
+ * at a time on a fresh track, which `validate()` takes from spine-core and the
10
+ * model side from rigc's core. The `check` call stays in `validate()`.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { SlotColourFacts } from '../facts/slot_colour.ts';
14
+ import { SKIP_NO_SEPARABLE_COLOR } from '../reasons.ts';
15
+ import { atStoredKey, isObj } from '../values.ts';
16
+ import { SLOT_COLOR_CHANNELS } from '../../timelines.ts';
17
+
18
+ // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
19
+ // `alpha` pose no dark colour, so under A43's name a failure would be a
20
+ // verdict saying "two-colour tint" about a file that states one colour —
21
+ // the name would say something other than what it decides (the rule #712
22
+ // applied to A43 itself, against a clause on A10). And the SKIP is the
23
+ // sharper half: a rig with a `dark` and no separable timeline has A43's
24
+ // subject present, so a clause there could only PASS on it, which is a pass
25
+ // for an absent subject.
26
+ //
27
+ // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
28
+ // clauses are about the file against the runtime:
29
+ //
30
+ // 1. **One channel, one timeline.** Each colour timeline poses its
31
+ // channels at EVERY time — before its first key it writes the setup
32
+ // value — so when two of one slot share a channel, the one applied
33
+ // later (the one the file states later) overwrites the other
34
+ // everywhere and the first one's keys on it are read by nothing
35
+ // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
36
+ // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
37
+ // loads without a word.
38
+ // 2. **Posed as written.** Stepped to each key's own time, the channels
39
+ // the timeline writes are the key's: a colour that is not six hex
40
+ // digits loads as NaN, and a key whose time another key repeats is
41
+ // read by nothing, and both parse in silence.
42
+ //
43
+ // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
44
+ // setup alpha is NOT this rule's to see, and nothing that reads only the
45
+ // file can see it: the result is a correct `rgba`, which is also what an
46
+ // author keying the light colour and holding its alpha would write. What
47
+ // differs is what happens UNDER another track that moves the alpha, which
48
+ // is the consumer's composing rather than the object (CLAUDE.md). That
49
+ // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
50
+ //
51
+ // ⚠️ The required values are parsed HERE, as A43's are, and the channel
52
+ // table is `timelines.ts`'s rather than the loaded timelines' property ids:
53
+ // a check that asked the parser what a timeline writes would agree with it
54
+ // whatever it did. A selftest control holds that table to the runtime's ids.
55
+ export function a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten({ fail, skip, stats }: Verdicts, facts: SlotColourFacts): void {
56
+ const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
57
+ /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
58
+ const readRgbHex = (hex: unknown): [number, number, number] | null => {
59
+ if (typeof hex !== 'string') return null;
60
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
61
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
62
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
63
+ };
64
+ /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
65
+ const HEX_STEP = 1 / 510;
66
+ const FLOAT_STEP = 1e-6;
67
+ const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
68
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
69
+
70
+ // -- the subjects, read off the FILE ---------------------------------
71
+ const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
72
+ for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
73
+ if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
74
+ subjects.push({ anim: animName, slot: slotName, timelines });
75
+ }
76
+ }
77
+ if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
78
+ stats.separableColorTimelines = subjects.reduce(
79
+ (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
80
+ 0,
81
+ );
82
+
83
+ for (const { anim: animName, slot: slotName, timelines } of subjects) {
84
+ const at = `animation "${animName}" slot "${slotName}"`;
85
+ // In FILE order, which is the order `readAnimation` pushes them and so
86
+ // the order they apply in.
87
+ const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
88
+
89
+ // -- clause 1: one channel, one timeline ---------------------------
90
+ let shared = false;
91
+ for (const channel of ['rgb', 'alpha', 'dark'] as const) {
92
+ const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
93
+ if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
94
+ shared = true;
95
+ const last = writers[writers.length - 1];
96
+ fail(
97
+ NAME,
98
+ `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
99
+ `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
100
+ `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
101
+ 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
102
+ );
103
+ }
104
+ if (shared) continue;
105
+
106
+ // -- clause 2: posed as written ------------------------------------
107
+ //
108
+ // ⚠️ Two things this does NOT do, both measured rather than skipped:
109
+ // it does not read the loaded timeline's CLASS back, and it does not
110
+ // hold a channel the timeline leaves alone to the setup pose. Against
111
+ // the linked parser neither can fail — every `rgb` / `alpha` array that
112
+ // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
113
+ // three other outcomes (an empty array, a slot the skeleton lacks, a
114
+ // name outside the switch) throw at `A00` — so either would be a clause
115
+ // nobody can see fire. The selftest reads both off spine-core directly
116
+ // (`S82`–`S84`), where they are measurements of the runtime rather than
117
+ // checks on a file.
118
+ if (!facts.hasAnimation(animName)) {
119
+ fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
120
+ continue;
121
+ }
122
+ for (const name of ['rgb', 'alpha'] as const) {
123
+ const keys = timelines[name];
124
+ if (!Array.isArray(keys)) continue;
125
+ const where = `${at} ${name}`;
126
+ for (const rawKey of keys) {
127
+ if (!isObj(rawKey)) continue;
128
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
129
+ // At the key as the runtime stores it, not one float step before
130
+ // it — see `atStoredKey` (issue #771).
131
+ const posed = facts.posedSlot(animName, slotName, atStoredKey(time));
132
+ if (!posed) {
133
+ fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
134
+ continue;
135
+ }
136
+ const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
137
+ if (name === 'rgb') {
138
+ const stated = readRgbHex(rawKey.color);
139
+ if (stated === null) {
140
+ fail(
141
+ NAME,
142
+ `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
143
+ `digits — and the runtime poses (${show(light.slice(0, 3))})`,
144
+ );
145
+ } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
146
+ fail(
147
+ NAME,
148
+ `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
149
+ `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
150
+ );
151
+ }
152
+ } else {
153
+ // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
154
+ // omits it there, so absence is read the parser's way rather than
155
+ // as a malformed key.
156
+ const stated = rawKey.value === undefined ? 0 : rawKey.value;
157
+ if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
158
+ fail(
159
+ NAME,
160
+ `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
161
+ (typeof stated !== 'number'
162
+ ? ' — an alpha key\'s value is a number'
163
+ : stated < 0 || stated > 1
164
+ ? ' — the runtime clamps a posed alpha to 0..1'
165
+ : ''),
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * A46, the body (issue #1025, cut 4c-3 of step 4c of #380): a numbered series
3
+ * shows the frame the file states.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * `SequenceFacts` (`../facts/sequences.ts`) where it read the skeleton JSON and
7
+ * the loaded skeleton — and for one change of key: what it knew about an
8
+ * attachment it used to file under the loaded attachment OBJECT, and now files
9
+ * under the entry's address (skin, slot, placeholder), the one key both sides
10
+ * hold. The runtime's side loads one object per address, so every map below
11
+ * holds what it held. The argument for the rule is above its `check` call,
12
+ * which stays in `validate()`.
13
+ */
14
+ import type { Verdicts } from '../harness.ts';
15
+ import { entryAddress, type EntryAddress, type SequenceFacts } from '../facts/sequences.ts';
16
+ import { attachmentRegionLookups } from '../region_lookups.ts';
17
+ import { SKIP_NO_SEQUENCE } from '../reasons.ts';
18
+ import { atStoredKey, isObj } from '../values.ts';
19
+ import { SEQUENCE_MODES } from '../../timelines.ts';
20
+
21
+ export function a46SequenceAttachmentsShowTheFrameTheFileStates({ fail, skip, stats }: Verdicts, facts: SequenceFacts): void {
22
+ const NAME = 'A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES';
23
+ type Series = { where: string; lookups: string[] | null; count: number; setup: number };
24
+ /** The loaded attachment's address -> what the FILE says its series is. */
25
+ const series = new Map<EntryAddress, Series>();
26
+ /** The addresses the runtime loaded an attachment at, for the timelines to find. */
27
+ const loadedAt = new Set<EntryAddress>();
28
+ /** Attachments whose block was already refused above — their timelines say nothing more. */
29
+ const refused = new Set<EntryAddress>();
30
+ const whole = (v: unknown, fallback: number): number | null =>
31
+ v === undefined ? fallback : typeof v === 'number' && Number.isInteger(v) ? v : null;
32
+ let blocks = 0;
33
+ for (const entry of facts.entries) {
34
+ const at = entryAddress(entry.skin, entry.slot, entry.placeholder);
35
+ const loaded = entry.loaded;
36
+ const where = `skin ${JSON.stringify(entry.skin)} slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.placeholder)}`;
37
+ if (loaded) loadedAt.add(at);
38
+ if (entry.sequence === undefined || entry.sequence === null) continue;
39
+ blocks++;
40
+ const seq = entry.sequence;
41
+ const name = typeof entry.name === 'string' ? entry.name : entry.placeholder;
42
+ const path = typeof entry.path === 'string' ? entry.path : name;
43
+ const count = isObj(seq) ? whole(seq.count, 0) : null;
44
+ const setup = isObj(seq) ? whole(seq.setup, 0) : null;
45
+ if ((count === null || setup === null || count < 1) && loaded) refused.add(at);
46
+ if (count === null || setup === null || count < 1) {
47
+ fail(
48
+ NAME,
49
+ `${where}: the sequence states ${JSON.stringify(seq)}` +
50
+ (isObj(seq) && seq.count === undefined
51
+ ? ' and no "count" — `readSequence` reads 0 (`SkeletonJson.js:644`), so the attachment loads holding no region and draws nothing'
52
+ : ' — "count" is a whole number of at least 1 and "setup" a whole number, or the series the parser builds is not the one written'),
53
+ );
54
+ continue;
55
+ }
56
+ if ((setup < 0 || setup >= count) && loaded) refused.add(at);
57
+ if (setup < 0 || setup >= count) {
58
+ fail(
59
+ NAME,
60
+ `${where}: the sequence's setup frame is ${setup} of a ${count}-frame series (frames 0 to ${count - 1}); ` +
61
+ '`Sequence.resolveIndex` clamps it, so the setup pose shows ' +
62
+ `${setup < 0 ? 'no frame at all' : `frame ${count - 1}, which the file does not name`}`,
63
+ );
64
+ continue;
65
+ }
66
+ if (!loaded) continue; // A00/A08 own an attachment that did not load
67
+ series.set(at, { where, lookups: attachmentRegionLookups(seq, path), count, setup });
68
+ }
69
+
70
+ /**
71
+ * The frame the file's statement gives for one key at one elapsed time —
72
+ * A46's prediction, not a call into the runtime. What holds it is the
73
+ * comparison it feeds: every sample sets it against the frame the posed
74
+ * slot shows, so a reading that disagreed with the runtime would fail A46
75
+ * on a correct file. A46 passing with samples posed on the selftest's
76
+ * series probes is that measurement for the modes they key; `M73` is its
77
+ * red half.
78
+ */
79
+ const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
80
+ if (mode === 'hold') return index;
81
+ let i = index + Math.trunc(elapsed / delay + 0.00001);
82
+ const n = count * 2 - 2;
83
+ switch (mode) {
84
+ case 'once':
85
+ return Math.min(count - 1, i);
86
+ case 'loop':
87
+ return i % count;
88
+ case 'pingpong':
89
+ i = n === 0 ? 0 : i % n;
90
+ return i >= count ? n - i : i;
91
+ case 'onceReverse':
92
+ return Math.max(count - 1 - i, 0);
93
+ case 'loopReverse':
94
+ return count - 1 - (i % count);
95
+ default: // pingpongReverse
96
+ i = n === 0 ? 0 : (i + count - 1) % n;
97
+ return i >= count ? n - i : i;
98
+ }
99
+ };
100
+
101
+ let timelines = 0;
102
+ let compared = 0;
103
+ let unshown = 0;
104
+ for (const { animation: animName, skin: skinName, slot: slotName, placeholder, keys } of facts.timelines) {
105
+ if (keys.length === 0) continue; // `readAnimation` skips it; A34 owns an empty timeline
106
+ timelines++;
107
+ const where = `animation ${JSON.stringify(animName)} ${skinName}/${slotName}/${placeholder} sequence`;
108
+ const keyed = entryAddress(skinName, slotName, placeholder);
109
+ if (!loadedAt.has(keyed)) continue; // the parser throws on a missing target: A00's
110
+ if (refused.has(keyed)) continue; // its block is already named above
111
+ const own = series.get(keyed);
112
+ if (own === undefined) {
113
+ fail(
114
+ NAME,
115
+ `${where}: the timeline steps an attachment that carries no "sequence" block. The parser gives it a ` +
116
+ 'series of ONE region (`readSequence(null)` is `new Sequence(1, false)`), so every mode shows that ' +
117
+ 'region at every time — measured: a "loop" key on a plain region showed it throughout',
118
+ );
119
+ continue;
120
+ }
121
+ // -- the keys, as the file states them --------------------------
122
+ type Key = { time: number; mode: string; index: number; delay: number };
123
+ const read: Key[] = [];
124
+ let carried = 0;
125
+ let malformed = false;
126
+ keys.forEach((rawKey, k) => {
127
+ const key = isObj(rawKey) ? rawKey : {};
128
+ const at = `${where} key ${k}`;
129
+ const time = typeof key.time === 'number' ? key.time : 0;
130
+ const mode = key.mode === undefined ? 'hold' : key.mode;
131
+ const index = key.index === undefined ? 0 : key.index;
132
+ if (key.delay !== undefined) carried = typeof key.delay === 'number' ? key.delay : Number.NaN;
133
+ if (typeof mode !== 'string' || !(SEQUENCE_MODES as readonly string[]).includes(mode)) {
134
+ malformed = true;
135
+ fail(
136
+ NAME,
137
+ `${at} (t=${time}): mode ${JSON.stringify(key.mode)} is not one of the ${SEQUENCE_MODES.length} the ` +
138
+ `format has (${SEQUENCE_MODES.join(', ')}); the parser reads \`SequenceMode[mode]\`, which is ` +
139
+ 'undefined, stores mode bits 0, and the key plays as "hold"',
140
+ );
141
+ return;
142
+ }
143
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= own.count) {
144
+ malformed = true;
145
+ fail(
146
+ NAME,
147
+ `${at} (t=${time}): index ${JSON.stringify(key.index)} is not a frame of the ${own.count}-frame series ` +
148
+ `(0 to ${own.count - 1}); the runtime stores \`index << 4\`, truncating a fraction, and ` +
149
+ '`Sequence.resolveIndex` clamps a frame past the end to the last one',
150
+ );
151
+ return;
152
+ }
153
+ if (mode !== 'hold' && !(carried > 0)) {
154
+ malformed = true;
155
+ fail(
156
+ NAME,
157
+ `${at} (t=${time}): "${mode}" at a delay of ${String(carried)}${key.delay === undefined ? ' (carried from the key before, 0 on the first)' : ''} — ` +
158
+ 'the frame advances by `(time - keyTime) / delay`, and at 0 that is Infinity, `Infinity | 0` is 0, ' +
159
+ 'and the key shows its first frame throughout: "hold" spelt as another mode',
160
+ );
161
+ return;
162
+ }
163
+ read.push({ time, mode, index, delay: carried });
164
+ });
165
+ if (malformed) continue;
166
+
167
+ // -- the pose, sampled ------------------------------------------
168
+ const end = facts.duration(animName);
169
+ if (end === null || !facts.hasSlot(slotName)) continue; // A00's
170
+ /** `key` is the index into `read`, or -1 before the first key (the setup frame). */
171
+ const samples: Array<{ time: number; key: number; steps: number }> = [];
172
+ if (read[0].time > 0) samples.push({ time: read[0].time / 2, key: -1, steps: 0 });
173
+ read.forEach((key, k) => {
174
+ const until = k + 1 < read.length ? read[k + 1].time : end;
175
+ if (key.mode === 'hold') {
176
+ samples.push({ time: key.time, key: k, steps: 0 });
177
+ return;
178
+ }
179
+ // Mid-frame, and enough steps to wrap every mode at least once.
180
+ for (let step = 0; step < own.count * 2 + 2; step++) {
181
+ const time = key.time + (step + 0.5) * key.delay;
182
+ if (time >= until || time > end) break;
183
+ samples.push({ time, key: k, steps: step + 0.5 });
184
+ }
185
+ });
186
+ let shownHere = 0;
187
+ for (const sample of samples) {
188
+ // A `hold` sample is AT its key, so it is posed at the key as
189
+ // the runtime stores it (`atStoredKey`); a mid-frame sample is
190
+ // half a delay from any key and is posed where it is.
191
+ const pose = facts.posedFrame(animName, slotName, sample.key >= 0 && sample.steps === 0 ? atStoredKey(sample.time) : sample.time);
192
+ // The slot must show the keyed attachment or one playing its
193
+ // timelines — the only case `applyToSlot` writes.
194
+ if (pose === null || (pose.shown !== keyed && pose.playsAs !== keyed)) continue;
195
+ const drawn = series.get(pose.shown);
196
+ if (drawn === undefined || drawn.lookups === null) continue;
197
+ shownHere++;
198
+ compared++;
199
+ // The frame count the runtime folds by is the SHOWN attachment's:
200
+ // a link with a series of its own steps it by its source's keys.
201
+ const key = sample.key < 0 ? null : read[sample.key];
202
+ const want = key === null ? drawn.setup : frameOf(key.mode, key.index, sample.time - key.time, key.delay, drawn.count);
203
+ const region = pose.region;
204
+ if (region !== drawn.lookups[want]) {
205
+ fail(
206
+ NAME,
207
+ `${where} (t=${Number(sample.time.toFixed(4))}): the slot shows region ${JSON.stringify(region)}, and ` +
208
+ `the file states frame ${want} of ${drawn.count} — ${JSON.stringify(drawn.lookups[want])} — ` +
209
+ (key === null
210
+ ? 'the setup frame, before the first key'
211
+ : `key ${sample.key} plays "${key.mode}" from frame ${key.index} every ${key.delay}s, ` +
212
+ `${sample.steps} delay(s) in`),
213
+ );
214
+ break;
215
+ }
216
+ }
217
+ if (shownHere === 0) unshown++;
218
+ }
219
+ if (blocks === 0 && timelines === 0) return skip(NAME, SKIP_NO_SEQUENCE);
220
+ stats.sequenceBlocks = blocks;
221
+ stats.sequenceTimelines = timelines;
222
+ stats.sequenceSamples = compared;
223
+ if (unshown > 0) stats.sequenceSamplesUnshown = unshown;
224
+ }