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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- 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
|
+
}
|