spine-rigc 1.9.0 → 1.9.2

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 (62) hide show
  1. package/cli.ts +48 -17
  2. package/docs/AUTHORING.md +2 -2
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a09.ts +82 -0
  5. package/src/assertions/bodies/a10.ts +116 -0
  6. package/src/assertions/bodies/a12.ts +30 -0
  7. package/src/assertions/bodies/a24.ts +68 -0
  8. package/src/assertions/bodies/a25.ts +39 -0
  9. package/src/assertions/bodies/a26.ts +61 -0
  10. package/src/assertions/bodies/a29.ts +34 -0
  11. package/src/assertions/bodies/a30.ts +50 -0
  12. package/src/assertions/bodies/a32.ts +44 -0
  13. package/src/assertions/bodies/a34.ts +133 -0
  14. package/src/assertions/bodies/a39.ts +303 -0
  15. package/src/assertions/bodies/a40.ts +128 -0
  16. package/src/assertions/bodies/a43.ts +181 -0
  17. package/src/assertions/bodies/a46.ts +224 -0
  18. package/src/assertions/facts/animation_durations.ts +37 -0
  19. package/src/assertions/facts/bone_timelines.ts +37 -0
  20. package/src/assertions/facts/constraint_targets.ts +56 -0
  21. package/src/assertions/facts/deform_survey.ts +27 -0
  22. package/src/assertions/facts/event_keys.ts +55 -0
  23. package/src/assertions/facts/sequences.ts +85 -0
  24. package/src/assertions/facts/skeleton_roster.ts +45 -0
  25. package/src/assertions/facts/slider_composition.ts +78 -0
  26. package/src/assertions/facts/stepped_poses.ts +74 -0
  27. package/src/assertions/facts/two_colour.ts +52 -0
  28. package/src/assertions/inward_advance.ts +58 -0
  29. package/src/assertions/model/animation_durations.ts +57 -0
  30. package/src/assertions/model/bone_timelines.ts +56 -0
  31. package/src/assertions/model/constraint_targets.ts +82 -0
  32. package/src/assertions/model/constraints.ts +34 -16
  33. package/src/assertions/model/deform_survey.ts +24 -0
  34. package/src/assertions/model/event_keys.ts +45 -0
  35. package/src/assertions/model/index.ts +83 -2
  36. package/src/assertions/model/linked_meshes.ts +2 -2
  37. package/src/assertions/model/mesh_attachments.ts +1 -1
  38. package/src/assertions/model/region_joins.ts +20 -12
  39. package/src/assertions/model/sequences.ts +157 -0
  40. package/src/assertions/model/skeleton_roster.ts +23 -0
  41. package/src/assertions/model/skin_entries.ts +18 -8
  42. package/src/assertions/model/skin_members.ts +5 -3
  43. package/src/assertions/model/skin_meshes.ts +1 -1
  44. package/src/assertions/model/slider_composition.ts +191 -0
  45. package/src/assertions/model/slot_colour.ts +5 -4
  46. package/src/assertions/model/stepped_poses.ts +89 -0
  47. package/src/assertions/model/two_colour.ts +61 -0
  48. package/src/assertions/model/vertex_polygons.ts +9 -8
  49. package/src/bonedist.ts +18 -6
  50. package/src/compile.ts +114 -14
  51. package/src/core/additive.ts +468 -0
  52. package/src/core/constraints.ts +12 -2
  53. package/src/core/constraints_physics.ts +31 -8
  54. package/src/core/raw.ts +77 -16
  55. package/src/core/walk.ts +202 -0
  56. package/src/deformmeasure.ts +35 -2270
  57. package/src/deformstructure.ts +60 -18
  58. package/src/deformsurvey.ts +2295 -0
  59. package/src/nonfinite.ts +54 -0
  60. package/src/render.ts +200 -59
  61. package/src/render_core.ts +5 -0
  62. package/src/validate.ts +791 -1558
@@ -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,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
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The animations and how long each one runs (issue #1025, cut 4c-3 of step
3
+ * 4c of #380) — the census's F18 and F19 as A09 reads them: the animation
4
+ * roster in the file's order, each animation's duration as the runtime holds
5
+ * it, and each of its timelines' own duration.
6
+ *
7
+ * ⭐ **The runtime's durations, not the stated ones.** An animation's duration
8
+ * is the last key of every timeline it carries, as float32 — the number
9
+ * `SkeletonJson` computes and stores, which is what A09 holds against the
10
+ * declared duration — and a timeline's own duration is its last key, as
11
+ * float32 (`Timeline.getDuration`). A09 counts the timelines keyed past the
12
+ * declared duration in its message, so the list holds one entry per timeline
13
+ * the runtime builds, in no order a body may read: A09 counts them and
14
+ * nothing else.
15
+ *
16
+ * The order of the roster is the file's: A09's last clause prints one line per
17
+ * animation the spec does not declare, in it.
18
+ *
19
+ * Links nothing from the runtime: `validate()` reads it off the loaded
20
+ * skeleton (`spineAnimationDurations`), the model side off the document and
21
+ * the core (`../model/animation_durations.ts`).
22
+ */
23
+
24
+ /** One animation, as A09 reads it. */
25
+ export interface AnimationDuration {
26
+ readonly name: string;
27
+ /** The runtime's duration: the last key of every timeline, float32. */
28
+ readonly duration: number;
29
+ /** Each timeline's own duration — its last key, float32 — one per timeline the runtime builds. */
30
+ readonly timelineDurations: readonly number[];
31
+ }
32
+
33
+ /** What A09 reads. */
34
+ export interface AnimationDurationFacts {
35
+ /** Every animation the skeleton holds, in the file's order. */
36
+ readonly animations: readonly AnimationDuration[];
37
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Every animation's bone timelines, in the file's order and spelling (issue
3
+ * #1025, cut 4c-4 of #380) — the half of the census's F18/F19 that the stroke
4
+ * rules read: which bones each animation keys, with which timelines, and each
5
+ * key as the file states it.
6
+ *
7
+ * ⭐ **The file's order, and the file's spelling**, for the reason A45's slot
8
+ * timelines carry both (`./slot_colour.ts`): A24 and A30 print one line per
9
+ * bone and per timeline in the order the file keys them, and A24, A29 and A30
10
+ * print a key's `x`, `y` and `time` as the file states them. So the list holds,
11
+ * for every animation in the order the file keys them and every bone in the
12
+ * order the animation keys them, that bone's timelines — name to keys — as
13
+ * the file spells them. `validate()` reads it off the skeleton JSON
14
+ * (`rawBoneTimelines`); the model side produces it from the document by calling
15
+ * the emitter's own order and its parser-default and key-order passes
16
+ * (`../model/bone_timelines.ts`), so a key field the emitter would leave out at
17
+ * the parser's default is left out here too.
18
+ *
19
+ * ⚠️ `timelines` is `unknown` rather than an object because the raw JSON can
20
+ * hold anything there and A24 counts a keyed bone before it asks what the
21
+ * bone's timelines are; the document always holds an object.
22
+ *
23
+ * Links nothing from the runtime.
24
+ */
25
+
26
+ /** One animation's timelines on one bone: timeline name -> keys, as the file states them. */
27
+ export interface BoneTimelines {
28
+ readonly animation: string;
29
+ readonly bone: string;
30
+ readonly timelines: unknown;
31
+ }
32
+
33
+ /** What A24, A29 and A30 read. */
34
+ export interface BoneTimelineFacts {
35
+ /** Every (animation, bone) pair the file keys, in the header's order. */
36
+ readonly boneTimelines: readonly BoneTimelines[];
37
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The constraint timelines of every animation, as A34 walks them (issue
3
+ * #1025, cut 4c-5 of step 4c of #380): the constraints the skeleton declares,
4
+ * and each animation's `ik`, `transform`, `path`, `physics` and `slider`
5
+ * groups as the Spine file states them — the constraint each entry names and
6
+ * the key arrays under it.
7
+ *
8
+ * ⭐ **The order is the file's.** The animations in the order the file keys
9
+ * them, in each the five groups in that fixed order, each group's entries in
10
+ * the order the file keys them, each entry's timelines likewise: A34 prints
11
+ * one line per finding, so the walk is a fact both suppliers state.
12
+ *
13
+ * 🔸 **Which constraints a physics timeline naming none reaches is a
14
+ * question, not a value** — the runtime answers it by building the timeline
15
+ * its parser builds for the name and asking each constraint's data whether it
16
+ * takes the key (`unnamedPhysicsReach`), the model side by the core's own
17
+ * reading (`posedPhysics`, through `unnamedReach`). A body asks for it
18
+ * (`reach`), and the selftest puts every question to both (`VF14`).
19
+ *
20
+ * Links nothing from the runtime.
21
+ */
22
+
23
+ /** One key array under an entry: the timeline's name (`''` for the `ik`/`transform` shape) and what it holds — how many keys, or the spelling of a value that is not a list. */
24
+ export interface TargetKeyArray {
25
+ readonly timeline: string;
26
+ readonly keys: { readonly count: number } | { readonly spelled: string };
27
+ }
28
+
29
+ /** One entry of a group: the constraint name the file keys it by, and either the spelling of a value that is not an object where named timelines go (`bare`) or its key arrays. */
30
+ export interface TargetEntry {
31
+ readonly name: string;
32
+ readonly bare: string | null;
33
+ readonly keyArrays: readonly TargetKeyArray[];
34
+ }
35
+
36
+ /** One animation: its name and the groups it states, in the walk's order. */
37
+ export interface TargetAnimation {
38
+ readonly name: string;
39
+ readonly groups: ReadonlyArray<{ readonly group: 'ik' | 'transform' | 'path' | 'physics' | 'slider'; readonly entries: readonly TargetEntry[] }>;
40
+ }
41
+
42
+ /** What a physics timeline naming no constraint reaches: whether it is a `reset`, and the constraints that take its key. */
43
+ export interface UnnamedReach {
44
+ readonly resets: boolean;
45
+ readonly reached: readonly string[];
46
+ }
47
+
48
+ /** What A34 reads. */
49
+ export interface ConstraintTargetFacts {
50
+ /** Every entry of the skeleton's `constraints` array that is an object: its name (null when it states none as a string), its type as spelled, and its name as the sentence spells it. */
51
+ readonly constraints: ReadonlyArray<{ readonly name: string | null; readonly type: string; readonly spelled: string }>;
52
+ /** Every animation, or `null` when the skeleton declares none (its `animations` is not an object). */
53
+ readonly groupsByAnimation: readonly TargetAnimation[] | null;
54
+ /** What a physics timeline named `timeline` that names no constraint reaches — or `null` for a name the parser builds no timeline for. */
55
+ reach(timeline: string): UnnamedReach | null;
56
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The deform survey (issue #1025, cut 4c-3 of step 4c of #380) — the
3
+ * census's R6 and the structure under it, as A39 reads them: every deform key
4
+ * of every animation measured in the frame its animation is reached in, every
5
+ * span between two keys scanned, and what was passed over.
6
+ *
7
+ * ⭐ **One fact, because it is one measurement.** The survey is what
8
+ * `explain`'s `DEFORM` block prints whole and A39 gates on, and the reason
9
+ * both read one survey rather than each posing for itself is that the
10
+ * report's reversal count and A39's are the same count
11
+ * (`src/deformmeasure.ts`'s header). So A39 is handed the survey, not the
12
+ * poses under it: `validate()` hands it spine-core's — the Spine skeleton read
13
+ * and posed by the runtime (`surveyDeformKeys`) — and the model side the core's
14
+ * — the model document's structure posed by rigc's core (`surveyOfModel`,
15
+ * `src/deformsurvey.ts`). `tools/survey_hashes.ts` holds the two to one survey
16
+ * on every corpus row, and the selftest on every call with a model in hand
17
+ * (`VF12`).
18
+ *
19
+ * Links nothing from the runtime.
20
+ */
21
+ import type { DeformSurvey } from '../../deformsurvey.ts';
22
+
23
+ /** What A39 reads. */
24
+ export interface DeformSurveyFacts {
25
+ /** The survey, with the slots in `exempt` passed over unmeasured (the rig's `deformMayFold`). */
26
+ survey(exempt: ReadonlySet<string>): DeformSurvey;
27
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Every event key, in the file's order, with the declaration it fires (issue
3
+ * #1025, cut 4c-4 of #380) — what A32 reads.
4
+ *
5
+ * ⭐ **Split per clause, and the split is in this interface.** A32 has five
6
+ * clauses. Four of them — a key with no string `name`, a key firing an event
7
+ * the skeleton does not declare, a key whose time is not a finite number, and
8
+ * a key earlier than the one before it — are states `readModel` refuses by
9
+ * name (`readEventKeys` in `src/core/events.ts`, measured on forged documents:
10
+ * each one is refused at the key's address), so no readable document reaches
11
+ * them and they stay with the round trip: `validate()` runs them over the
12
+ * skeleton JSON and hands their findings in as `kept`, in the order they
13
+ * print, with `stopped` where the clause ended the key's reading. The fifth —
14
+ * `volume` or `balance` on a key whose event declares no audio — is the rig's:
15
+ * the document holds the key's fields and the declaration's `audio`, and a
16
+ * readable document can carry it. That clause is the body's
17
+ * (`../bodies/a32.ts`), and the model side supplies `kept` empty.
18
+ *
19
+ * The order is the file's: animations in the order the file keys them (the
20
+ * emitter's `editorAnimationOrder`), and an animation's keys in the order the
21
+ * document lists them, which is the order the emitter writes them
22
+ * (`emitAnimation`'s `events`). `timelines` counts the animations carrying an
23
+ * event timeline at all, which is what A32 SKIPs on when it is zero.
24
+ *
25
+ * Links nothing from the runtime.
26
+ */
27
+
28
+ /** One event key: where it is, what the kept clauses found, and what the moved clause reads. */
29
+ export interface EventKeyEntry {
30
+ readonly animation: string;
31
+ /** The key's index in its animation's event timeline. */
32
+ readonly index: number;
33
+ /** The findings of the clauses that stay with the round trip, in the order they print — always empty on the model side. */
34
+ readonly kept: readonly string[];
35
+ /** Whether a kept clause ended the key's reading, so the moved clause does not run on it. */
36
+ readonly stopped: boolean;
37
+ /** The event the key fires. */
38
+ readonly name: string;
39
+ /** Which of the two audio fields the key states. */
40
+ readonly sets: { readonly volume: boolean; readonly balance: boolean };
41
+ /** Whether the event the key fires declares an audio path (a string). */
42
+ readonly audio: boolean;
43
+ }
44
+
45
+ /** What A32 reads. */
46
+ export interface EventKeyFacts {
47
+ /** Whether the skeleton JSON parsed — always true on the model side, whose parse is the reader's. */
48
+ readonly parsed: boolean;
49
+ /** Whether the skeleton declares an `animations` object at all. */
50
+ readonly animations: boolean;
51
+ /** How many animations carry an event timeline. */
52
+ readonly timelines: number;
53
+ /** Every event key, in the file's order. */
54
+ readonly keys: readonly EventKeyEntry[];
55
+ }