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,1641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor round trip, as a tool (issue #374).
|
|
3
|
+
*
|
|
4
|
+
* `build` โ import into the Spine editor โ export back to JSON โ gate, `diff`,
|
|
5
|
+
* `render` and `check` the export against the build it came from โ the last two
|
|
6
|
+
* **once per skin both files declare** (issue #571; see `skinBlocks`), with a
|
|
7
|
+
* skin only one of them declares named as lost or added by the export and
|
|
8
|
+
* rendered on neither side (issue #801; see `skinRoster`). It is the
|
|
9
|
+
* one measurement that answers *"does the editor accept what rigc wrote, and does
|
|
10
|
+
* what comes back still play the same"*, and on its first run it found three
|
|
11
|
+
* emitter defects (#368 `hull`/`edges`, #369 hold curves, #370
|
|
12
|
+
* `skeleton.images`) before proving that a human edit survives the trip.
|
|
13
|
+
*
|
|
14
|
+
* โญ Every step quotes what its child said when that child did not do what it was
|
|
15
|
+
* for (#541, and step 5 since #621). A skin NEITHER side can draw is a SKIP
|
|
16
|
+
* naming that rather than a red โ `check` had nothing to compare, and `validate`
|
|
17
|
+
* and `diff` have already measured the rig โ while a skin only ONE side draws is
|
|
18
|
+
* the divergence this whole file exists to find and stays a failure.
|
|
19
|
+
*
|
|
20
|
+
* It ran from a shell script in a local scratch directory. A tool nobody can
|
|
21
|
+
* find is not a tool, hence this file.
|
|
22
|
+
*
|
|
23
|
+
* ## Which editor the report names
|
|
24
|
+
*
|
|
25
|
+
* Step 0's version line is the one place the report says which editor the trip
|
|
26
|
+
* ran on. It is read from the editor's `--version` started with the SAME `-u`
|
|
27
|
+
* the import and the export carry (`--editor-version`), so a pinned run names
|
|
28
|
+
* the pinned editor, and says so on that line: `(read from --version under -u
|
|
29
|
+
* <v>, โฆ)`. An unpinned run prints the line alone, read from `--version` with no
|
|
30
|
+
* `-u` โ the editor the launcher starts by default. Once the export exists,
|
|
31
|
+
* step 2 holds that version against the export's own `skeleton.spine`, prints
|
|
32
|
+
* one line saying which it read and that the two agree, and when they DISAGREE
|
|
33
|
+
* prints a `FAIL` naming both values and ends the run non-zero: a version line
|
|
34
|
+
* that names an editor the trip did not export on is a fault in the instrument,
|
|
35
|
+
* and a green run carrying it would be a measurement naming the wrong
|
|
36
|
+
* instrument (issue #1199, where a bare `--version` named the launcher's
|
|
37
|
+
* default, 4.3.26, on a trip pinned to and exported by 4.3.23).
|
|
38
|
+
*
|
|
39
|
+
* ## What it is not
|
|
40
|
+
*
|
|
41
|
+
* ๐ **It is not a way to get Spine data without the editor** โ it is the
|
|
42
|
+
* opposite, a harness that requires one. It drives only the documented command
|
|
43
|
+
* line (https://esotericsoftware.com/spine-command-line-interface), never the
|
|
44
|
+
* UI, and it produces nothing the editor did not produce. rigc links
|
|
45
|
+
* `spine-core` and is covered by the Spine Runtimes License; this tool needs a
|
|
46
|
+
* licensed *editor* on the machine as well, by construction.
|
|
47
|
+
*
|
|
48
|
+
* โ **The round trip can never be a selftest control.** The suite is
|
|
49
|
+
* self-contained and CI has no editor; a control that needs one would report
|
|
50
|
+
* SKIP forever, which is how a gate comes to look kept while checking nothing.
|
|
51
|
+
* When the editor is absent, or is the trial, this tool REFUSES by name and
|
|
52
|
+
* exits non-zero โ the honest answer, and never a pass.
|
|
53
|
+
*
|
|
54
|
+
* โญ Those REFUSALS are gated, and they are the half of this file a machine with
|
|
55
|
+
* no editor can answer for (issue #410). The `ERT` suite in `selftest.ts` points
|
|
56
|
+
* the tool at stubs in a temp directory and reads what comes back; it needs no
|
|
57
|
+
* editor, because the question is whether the refusal fires and what it says.
|
|
58
|
+
* The no-editor branch had never executed anywhere until 2026-09-05 โ it was
|
|
59
|
+
* written on the machine that has the editor โ which is precisely the shape of
|
|
60
|
+
* a gate nobody has seen fail.
|
|
61
|
+
*/
|
|
62
|
+
import { spawnSync } from 'node:child_process';
|
|
63
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
64
|
+
import { basename, join, resolve } from 'node:path';
|
|
65
|
+
|
|
66
|
+
/** Where the editor lives, per platform, from Esoteric's own CLI page. */
|
|
67
|
+
export const EDITOR_DEFAULTS: Record<string, string> = {
|
|
68
|
+
darwin: '/Applications/Spine.app/Contents/MacOS/Spine',
|
|
69
|
+
win32: 'C:\\Program Files\\Spine\\Spine.com',
|
|
70
|
+
linux: '/opt/Spine/Spine',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every editor call is bounded.
|
|
75
|
+
*
|
|
76
|
+
* โ ๏ธ Not defensive tidiness: on the first run the trial launcher opened a
|
|
77
|
+
* WINDOW and waited for someone to click it, with the harness holding the
|
|
78
|
+
* terminal. A round trip that hangs forever is indistinguishable from one that
|
|
79
|
+
* is working, so the wall clock is part of the measurement.
|
|
80
|
+
*/
|
|
81
|
+
const DEFAULT_TIMEOUT_S = 600;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The tail both refusals end on.
|
|
85
|
+
*
|
|
86
|
+
* โญ Named once because the second clause is the point of it (issue #410): the
|
|
87
|
+
* person reading either refusal has just found out they cannot run the round
|
|
88
|
+
* trip, and `--exported` is the one thing they *can* run โ its own usage text
|
|
89
|
+
* says it exists "so the measuring half can be exercised on a machine with no
|
|
90
|
+
* editor installed", and the refusals were the only place that never said so.
|
|
91
|
+
*/
|
|
92
|
+
const NO_EDITOR_HINT =
|
|
93
|
+
'Pass --editor <path to a licensed editor>, or run this on a machine that has one installed. ' +
|
|
94
|
+
'What you can still do here: --exported <file> measures an export the editor ALREADY made, skips steps 1-2 ' +
|
|
95
|
+
'and runs every measurement below. ' +
|
|
96
|
+
'See https://esotericsoftware.com/spine-command-line-interface';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A name that reads "Spine Trial" โ the executable's, the bundle's, or the one
|
|
100
|
+
* the bundle declares.
|
|
101
|
+
*
|
|
102
|
+
* โ ๏ธ Anchored, not a substring search. `spine-trial-comparison/Spine` is a
|
|
103
|
+
* directory somebody named, not a trial, and refusing it would lock out exactly
|
|
104
|
+
* the caller `--editor` exists for.
|
|
105
|
+
*/
|
|
106
|
+
const TRIAL_NAME = /^spine[\s_-]*trial$/i;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The launcher banner the trial prints, as MEASURED on this machine 2026-09-05:
|
|
110
|
+
*
|
|
111
|
+
* Spine Launcher 4.3.06 Trial (macOS Apple Silicon)
|
|
112
|
+
*
|
|
113
|
+
* โ ๏ธ What the LICENSED editor prints on that line has not been measured here โ
|
|
114
|
+
* there is no licensed editor on the machine this was written on. So the match
|
|
115
|
+
* is anchored to the launcher's own banner line and to `Trial` standing alone
|
|
116
|
+
* inside it, rather than to "trial appears somewhere in the output": a build
|
|
117
|
+
* path with the word in it must not be able to refuse a real editor.
|
|
118
|
+
*/
|
|
119
|
+
const TRIAL_BANNER = /^[^\n]*\bSpine Launcher\b[^\n]*\bTrial\b[^\n]*$/im;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Everything about this path that says it is the Spine TRIAL, phrased as what
|
|
123
|
+
* was found and where.
|
|
124
|
+
*
|
|
125
|
+
* ๐ Read off the FILE SYSTEM, never by running it โ running it is the hazard
|
|
126
|
+
* this exists to avoid. `--version` on
|
|
127
|
+
* `/Applications/SpineTrial.app/Contents/MacOS/Spine Trial` (4.3.06, macOS)
|
|
128
|
+
* prints its banner and then does not return: measured 2026-09-05 and killed at
|
|
129
|
+
* a 20 s bound, and the first attempt at this round trip on 2026-09-04 held the
|
|
130
|
+
* terminal for over three minutes.
|
|
131
|
+
*
|
|
132
|
+
* โ ๏ธ Ambiguity is not a signal. A path that is neither obviously the trial nor
|
|
133
|
+
* obviously the licensed editor comes back with an EMPTY list and the run
|
|
134
|
+
* proceeds to fail โ or succeed โ on its own merits. rigc does not have the
|
|
135
|
+
* authority to guess its input away, and a false refusal here would lock out
|
|
136
|
+
* someone with a perfectly good editor at a nonstandard path.
|
|
137
|
+
*/
|
|
138
|
+
function trialSignalsFromPath(editor: string): string[] {
|
|
139
|
+
const found: string[] = [];
|
|
140
|
+
const exe = editor.split(/[/\\]/).filter((s) => s !== '').pop() ?? '';
|
|
141
|
+
if (TRIAL_NAME.test(exe.replace(/\.(exe|com|bat|cmd)$/i, ''))) found.push(`its executable is named "${exe}"`);
|
|
142
|
+
|
|
143
|
+
// The application bundle it sits in, and what that bundle says it is. Both are
|
|
144
|
+
// macOS shapes; on a platform with no bundle neither fires and neither lies.
|
|
145
|
+
const bundleDir = /^(.*?\.app)(?:[/\\]|$)/i.exec(editor)?.[1];
|
|
146
|
+
if (bundleDir !== undefined) {
|
|
147
|
+
const bundle = bundleDir.split(/[/\\]/).pop() ?? '';
|
|
148
|
+
if (TRIAL_NAME.test(bundle.replace(/\.app$/i, ''))) found.push(`it sits inside the bundle "${bundle}"`);
|
|
149
|
+
const declared = bundleName(join(bundleDir, 'Contents', 'Info.plist'));
|
|
150
|
+
if (declared !== null && TRIAL_NAME.test(declared)) {
|
|
151
|
+
found.push(`its bundle's Info.plist declares CFBundleName "${declared}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `CFBundleName` out of an XML `Info.plist`, or null when there is nothing
|
|
159
|
+
* readable there. A plist this cannot read is not a signal โ it is silence, and
|
|
160
|
+
* silence lets the run proceed.
|
|
161
|
+
*/
|
|
162
|
+
function bundleName(plist: string): string | null {
|
|
163
|
+
if (!existsSync(plist)) return null;
|
|
164
|
+
try {
|
|
165
|
+
const m = /<key>\s*CFBundleName\s*<\/key>\s*<string>([^<]*)<\/string>/i.exec(readFileSync(plist, 'utf8'));
|
|
166
|
+
return m === null ? null : m[1].trim();
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The editor naming its own version, as MEASURED on a licensed 4.3 editor on
|
|
174
|
+
* macOS 2026-10-02 (issue #1077). `--version` printed nine lines on stdout and
|
|
175
|
+
* none on stderr, in this shape:
|
|
176
|
+
*
|
|
177
|
+
* the launcher's banner `Spine Launcher <x.y.z> (<platform>)`
|
|
178
|
+
* the copyright line
|
|
179
|
+
* the operating system and its version
|
|
180
|
+
* `Starting: Spine <x.y.z> Professional`
|
|
181
|
+
* `Spine <x.y.z> Professional` <- this line
|
|
182
|
+
* `Licensed to:`
|
|
183
|
+
* the licensee's name
|
|
184
|
+
* the licensee's e-mail address
|
|
185
|
+
* `Complete.`
|
|
186
|
+
*
|
|
187
|
+
* ๐จ The tool used to print the last three of those, which are the licensee's
|
|
188
|
+
* name, e-mail and `Complete.` โ personal data in a log that gets pasted into
|
|
189
|
+
* PR bodies and cards, and no version at all (issue #1040 found it). So the
|
|
190
|
+
* report keeps ONE line, the one the editor starts with its own name and a
|
|
191
|
+
* version, and nothing else from that output.
|
|
192
|
+
*
|
|
193
|
+
* โ ๏ธ Anchored at the start of the line and to a version straight after the
|
|
194
|
+
* word, which is what separates it from its neighbours: the launcher's banner
|
|
195
|
+
* has `Launcher` there (and is a different version), and the `Starting:` line
|
|
196
|
+
* does not start with `Spine`. A line that merely contains a version somewhere โ
|
|
197
|
+
* the operating system's โ is not the editor's.
|
|
198
|
+
*/
|
|
199
|
+
const EDITOR_VERSION_LINE = /^Spine[ \t]+\d+\.\d+\.\d+\b[^\n]*$/m;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What step 0 prints for the editor's version โ its own line, or a sentence
|
|
203
|
+
* saying it was not there โ and the `x.y.z` that line names, which is what the
|
|
204
|
+
* export's own `skeleton.spine` is checked against once the export exists
|
|
205
|
+
* (issue #1199). `version` is null exactly when `line` is the absence.
|
|
206
|
+
*/
|
|
207
|
+
function editorVersion(output: string): { line: string; version: string | null } {
|
|
208
|
+
const found = EDITOR_VERSION_LINE.exec(output);
|
|
209
|
+
if (found === null) return { line: 'editor version: not found in --version output', version: null };
|
|
210
|
+
return { line: found[0].trim(), version: /^Spine[ \t]+(\d+\.\d+\.\d+)/.exec(found[0])?.[1] ?? null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where step 0 read the editor's version, in the words the report prints it in
|
|
215
|
+
* (issue #1199): the `--version` call, and the `-u` it carried or the absence of
|
|
216
|
+
* one โ which on an unpinned run means the editor the launcher starts by default.
|
|
217
|
+
*/
|
|
218
|
+
function versionSource(pinned: string | null): string {
|
|
219
|
+
return pinned === null
|
|
220
|
+
? "read from --version with no -u: the editor the launcher starts by default"
|
|
221
|
+
: `read from --version under -u ${pinned}, the pin every editor call of this trip carries`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The `skeleton.spine` an export declares, or null when it declares none or the
|
|
226
|
+
* file cannot be read as a skeleton โ read off the FILE, as `shapeOf` reads it,
|
|
227
|
+
* because it is the editor's own statement of the data version it exported.
|
|
228
|
+
* Null is not swallowed: the caller states that nothing was there to check.
|
|
229
|
+
*/
|
|
230
|
+
function declaredSpineVersion(path: string): string | null {
|
|
231
|
+
try {
|
|
232
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
233
|
+
if (!isRecord(parsed) || !isRecord(parsed.skeleton)) return null;
|
|
234
|
+
const spine = parsed.skeleton.spine;
|
|
235
|
+
return typeof spine === 'string' ? spine : null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The line step 2 prints once the export exists: the version step 0 read held
|
|
243
|
+
* against the export's own `skeleton.spine` (issue #1199). `fail` is true only
|
|
244
|
+
* when both are there and differ โ a disagreement is a finding about the
|
|
245
|
+
* INSTRUMENT, not a pass, so it fails the trip; a side with nothing to compare is
|
|
246
|
+
* stated and fails nothing, the way an absent version line has never been a
|
|
247
|
+
* refusal (#1077).
|
|
248
|
+
*/
|
|
249
|
+
export function versionAgreement(
|
|
250
|
+
read: string | null,
|
|
251
|
+
pinned: string | null,
|
|
252
|
+
declared: string | null,
|
|
253
|
+
): { line: string; fail: boolean } {
|
|
254
|
+
const source = versionSource(pinned);
|
|
255
|
+
if (declared === null) {
|
|
256
|
+
return {
|
|
257
|
+
line: ` the export declares no skeleton.spine, so the version step 0 ${read === null ? 'did not find' : `read (${read}, ${source})`} is not checked against it`,
|
|
258
|
+
fail: false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (read === null) {
|
|
262
|
+
return {
|
|
263
|
+
line: ` the export declares skeleton.spine "${declared}"; step 0 found no version line (${source}) to check it against`,
|
|
264
|
+
fail: false,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (read === declared) {
|
|
268
|
+
return { line: ` the export declares skeleton.spine "${declared}", the version step 0 read (${source})`, fail: false };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
line:
|
|
272
|
+
` FAIL step 0 read the editor as ${read} (${source}) and the export declares skeleton.spine "${declared}" โ ` +
|
|
273
|
+
'the version line names an editor this trip did not export on, which is a fault in the instrument rather ' +
|
|
274
|
+
'than in the rig, so the trip is not green',
|
|
275
|
+
fail: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The trial naming itself in its own `--version` output, or null. */
|
|
280
|
+
function trialSignalFromVersion(output: string): string | null {
|
|
281
|
+
const banner = TRIAL_BANNER.exec(output);
|
|
282
|
+
return banner === null ? null : `it introduces itself as "${banner[0].trim()}"`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The refusal, in the doctrine's shape: the object, what was found, what is
|
|
287
|
+
* required, and what the reader can do instead.
|
|
288
|
+
*/
|
|
289
|
+
function trialRefusal(editor: string, signals: string[]): string {
|
|
290
|
+
return (
|
|
291
|
+
`the editor at ${editor} is the Spine TRIAL, not a licensed editor โ ${signals.join('; ')}. ` +
|
|
292
|
+
'The trial cannot save projects or export animation data, which is the whole of what steps 1-2 do, so ' +
|
|
293
|
+
'there is no round trip to be had on it; worse, it does not fail cleanly โ a trial call with no export ' +
|
|
294
|
+
'verb has been measured opening a window and never returning. This tool needs the licensed editor, whose ' +
|
|
295
|
+
'bundle and executable are named "Spine". ' +
|
|
296
|
+
NO_EDITOR_HINT
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface Options {
|
|
301
|
+
build: string;
|
|
302
|
+
out: string;
|
|
303
|
+
name: string;
|
|
304
|
+
editor: string;
|
|
305
|
+
editorVersion: string | null;
|
|
306
|
+
fps: number;
|
|
307
|
+
timeoutS: number;
|
|
308
|
+
/** Measure an export the editor already made, instead of making one. */
|
|
309
|
+
exported: string | null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function usage(): never {
|
|
313
|
+
console.error(
|
|
314
|
+
[
|
|
315
|
+
'usage: bun tools/editor_roundtrip.ts --build <dir> [flags]',
|
|
316
|
+
'',
|
|
317
|
+
' --build <dir> a rigc build directory (skeleton.json + atlas + pages) REQUIRED',
|
|
318
|
+
' --out <dir> where the round trip writes (default <build>/../roundtrip)',
|
|
319
|
+
' --name <name> skeleton name given to the import (default the build dir\'s name)',
|
|
320
|
+
` --editor <path> the Spine editor executable (default ${EDITOR_DEFAULTS[process.platform] ?? '(unknown for this platform)'})`,
|
|
321
|
+
' --editor-version <v> pin the editor with -u, e.g. 4.3.xx (default: let the editor choose)',
|
|
322
|
+
' --fps <n> render rate for the check (default 12)',
|
|
323
|
+
' --timeout <s> bound on each editor call (default 600)',
|
|
324
|
+
' --exported <file> measure an export the editor ALREADY made and skip steps 1-2.',
|
|
325
|
+
' Not a bypass: the editor still produced the file, and every',
|
|
326
|
+
' measurement below still runs. It exists so the measuring half',
|
|
327
|
+
' can be exercised on a machine with no editor installed.',
|
|
328
|
+
'',
|
|
329
|
+
'Step 5 renders and checks once per skin BOTH files declare; a skin only one of them',
|
|
330
|
+
'declares is a FAIL naming it as lost (or added) by the export, and is rendered on',
|
|
331
|
+
'neither side.',
|
|
332
|
+
'',
|
|
333
|
+
'Requires a licensed Spine editor on this machine. It drives the documented command',
|
|
334
|
+
'line only, and refuses by name when the editor is not there.',
|
|
335
|
+
].join('\n'),
|
|
336
|
+
);
|
|
337
|
+
process.exit(2);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseArgs(argv: string[]): Options {
|
|
341
|
+
const flags = new Map<string, string>();
|
|
342
|
+
for (let i = 0; i < argv.length; i++) {
|
|
343
|
+
const a = argv[i];
|
|
344
|
+
if (!a.startsWith('--')) usage();
|
|
345
|
+
const key = a.slice(2);
|
|
346
|
+
const value = argv[++i];
|
|
347
|
+
if (value === undefined || value.startsWith('--')) {
|
|
348
|
+
console.error(`rigc editor_roundtrip: --${key} needs a value`);
|
|
349
|
+
process.exit(2);
|
|
350
|
+
}
|
|
351
|
+
flags.set(key, value);
|
|
352
|
+
}
|
|
353
|
+
const build = flags.get('build');
|
|
354
|
+
if (build === undefined) usage();
|
|
355
|
+
const known = new Set(['build', 'out', 'name', 'editor', 'editor-version', 'fps', 'timeout', 'exported']);
|
|
356
|
+
for (const key of flags.keys()) {
|
|
357
|
+
if (!known.has(key)) {
|
|
358
|
+
console.error(`rigc editor_roundtrip: unknown flag --${key}`);
|
|
359
|
+
process.exit(2);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const buildDir = resolve(build);
|
|
363
|
+
return {
|
|
364
|
+
build: buildDir,
|
|
365
|
+
out: resolve(flags.get('out') ?? join(buildDir, '..', 'roundtrip')),
|
|
366
|
+
name: flags.get('name') ?? basename(buildDir),
|
|
367
|
+
editor: flags.get('editor') ?? EDITOR_DEFAULTS[process.platform] ?? '',
|
|
368
|
+
editorVersion: flags.get('editor-version') ?? null,
|
|
369
|
+
fps: Number(flags.get('fps') ?? 12),
|
|
370
|
+
timeoutS: Number(flags.get('timeout') ?? DEFAULT_TIMEOUT_S),
|
|
371
|
+
exported: flags.get('exported') === undefined ? null : resolve(flags.get('exported')!),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The lines of a rigc report worth putting in the round-trip table: its verdict,
|
|
377
|
+
* and anything that failed.
|
|
378
|
+
*
|
|
379
|
+
* โ ๏ธ Written after the first version took `stdout.slice(-6)`, which on `check`
|
|
380
|
+
* lands in the middle of the block explaining what a column MEANS โ six lines of
|
|
381
|
+
* correct prose where the reader wanted one number. A tail is not a summary.
|
|
382
|
+
*/
|
|
383
|
+
function verdictLines(out: string): string[] {
|
|
384
|
+
const lines = out.trim().split('\n');
|
|
385
|
+
const kept = lines.filter((l) => /^\s*(FAIL|rigc:)/.test(l) || /\bexit=\d/.test(l));
|
|
386
|
+
const last = lines[lines.length - 1];
|
|
387
|
+
if (kept.length === 0 && last !== undefined) return [last];
|
|
388
|
+
return kept.slice(-6);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
interface Ran {
|
|
392
|
+
status: number | null;
|
|
393
|
+
stdout: string;
|
|
394
|
+
stderr: string;
|
|
395
|
+
/** The bound fired: the call is not a result, it is a hang. */
|
|
396
|
+
timedOut: boolean;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The indices of the lines in one stream of editor output that carry the
|
|
401
|
+
* licence holder, and nothing else (issue #1082).
|
|
402
|
+
*
|
|
403
|
+
* Measured on a licensed 4.3 editor on macOS 2026-10-02, through this tool, on
|
|
404
|
+
* four failing calls. Every one exited 1, wrote everything to stdout and nothing
|
|
405
|
+
* to stderr, and printed one of two shapes:
|
|
406
|
+
*
|
|
407
|
+
* an input path that does not exist (import, and export) โ refused by the
|
|
408
|
+
* LAUNCHER before the editor starts, and no licence line at all:
|
|
409
|
+
*
|
|
410
|
+
* the launcher's banner, the copyright line, the operating system
|
|
411
|
+
* (blank) `Parameter: --input <path>` (blank)
|
|
412
|
+
* `ERROR: Input path does not exist:`
|
|
413
|
+
* the path
|
|
414
|
+
*
|
|
415
|
+
* a file the editor cannot read (a skeleton JSON naming a parent bone it does
|
|
416
|
+
* not declare; a `.spine` that is not a project) โ the editor starts, names
|
|
417
|
+
* itself and its licence holder, and then the failure:
|
|
418
|
+
*
|
|
419
|
+
* the launcher's banner, the copyright line, the operating system
|
|
420
|
+
* `Starting: Spine <x.y.z> Professional`
|
|
421
|
+
* `Spine <x.y.z> Professional`
|
|
422
|
+
* `Licensed to: <name> <<e-mail>>` <- ONE line, withheld
|
|
423
|
+
* import: `Project import: โฆ`, `ERROR: Unable to import skeleton.`, then
|
|
424
|
+
* export: `ERROR: Unable to export.`, then
|
|
425
|
+
* `[error] โฆ` lines, a stack, and `Cause: โฆ` lines
|
|
426
|
+
*
|
|
427
|
+
* ๐จ So on a failure the block is one line, not the three `--version` prints
|
|
428
|
+
* (`Licensed to:` alone, then the name, then the e-mail โ issue #1077), and
|
|
429
|
+
* the lines straight after it are the editor's own error: a rule that took
|
|
430
|
+
* "the header and the two lines after it" would have quoted the licence holder
|
|
431
|
+
* not at all and the reason for the failure not at all either.
|
|
432
|
+
*
|
|
433
|
+
* โ ๏ธ Both shapes are held, each by what bounds it. A header with a value on the
|
|
434
|
+
* same line is that one line. A bare header takes the two lines after it only
|
|
435
|
+
* when the second of them is an e-mail (`@`), which is the `--version` shape;
|
|
436
|
+
* anything else after a bare header is the editor talking and is quoted.
|
|
437
|
+
*/
|
|
438
|
+
function licenceLines(lines: readonly string[]): Set<number> {
|
|
439
|
+
const withheld = new Set<number>();
|
|
440
|
+
for (let i = 0; i < lines.length; i++) {
|
|
441
|
+
const header = /^[ \t]*Licensed to:(.*)$/.exec(lines[i]);
|
|
442
|
+
if (header === null) continue;
|
|
443
|
+
withheld.add(i);
|
|
444
|
+
if (header[1].trim() !== '') continue;
|
|
445
|
+
if (i + 2 < lines.length && lines[i + 2].includes('@')) {
|
|
446
|
+
withheld.add(i + 1);
|
|
447
|
+
withheld.add(i + 2);
|
|
448
|
+
i += 2;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return withheld;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Everything the editor printed on a step that did not do what it was for.
|
|
456
|
+
*
|
|
457
|
+
* ๐จ This is the defect issue #541 is half about, and it was this tool's. A
|
|
458
|
+
* four-skin rig would not import; the report said
|
|
459
|
+
*
|
|
460
|
+
* ## 1 import (json -> project)
|
|
461
|
+
* exit=1
|
|
462
|
+
* rigc editor_roundtrip: the editor wrote no project file; the import did not happen
|
|
463
|
+
*
|
|
464
|
+
* and the card was filed as *"the editor refuses it without a word"*. The editor
|
|
465
|
+
* had not been silent at all โ it named the section, the attachment and the rule:
|
|
466
|
+
*
|
|
467
|
+
* ERROR: Unable to import skeleton.
|
|
468
|
+
* [error] Error reading skeleton: skins
|
|
469
|
+
* Cause: [error] Error reading attachment: patch (MOw)
|
|
470
|
+
* Cause: [error] Multiple attachments have the same name: patch patch
|
|
471
|
+
*
|
|
472
|
+
* `run` captured both streams and the report printed neither. A day of bisecting
|
|
473
|
+
* the emitted file rediscovered what one of those lines says outright, and the
|
|
474
|
+
* repository whose whole doctrine is *convert silence into a named failure* had
|
|
475
|
+
* manufactured the silence.
|
|
476
|
+
*
|
|
477
|
+
* โญ The refusal is unchanged and stays unchanged: a step that did not produce
|
|
478
|
+
* its artifact is still a refusal by name, and this adds the reason rather than
|
|
479
|
+
* softening the verdict. What it prints is the editor's own words, quoted and
|
|
480
|
+
* attributed to the stream they came off, and never rewritten โ a harness that
|
|
481
|
+
* summarised them would be the same defect with a smaller radius.
|
|
482
|
+
*
|
|
483
|
+
* ๐จ With one exception, which is not the editor's words but the licence
|
|
484
|
+
* holder's (issue #1082): the licence line, which `licenceLines` names and
|
|
485
|
+
* which is replaced by one line saying it was withheld. Every other line is
|
|
486
|
+
* quoted as printed, in order.
|
|
487
|
+
*/
|
|
488
|
+
function editorSaid(ran: Ran): string[] {
|
|
489
|
+
const lines: string[] = [];
|
|
490
|
+
for (const [stream, text] of [
|
|
491
|
+
['stdout', ran.stdout],
|
|
492
|
+
['stderr', ran.stderr],
|
|
493
|
+
] as const) {
|
|
494
|
+
const body = text.replace(/\s+$/, '');
|
|
495
|
+
if (body === '') continue;
|
|
496
|
+
lines.push(` the editor's ${stream}:`);
|
|
497
|
+
const printed = body.split('\n');
|
|
498
|
+
const withheld = licenceLines(printed);
|
|
499
|
+
for (let i = 0; i < printed.length; i++) {
|
|
500
|
+
if (!withheld.has(i)) {
|
|
501
|
+
lines.push(` | ${printed[i]}`);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
// One note per run of withheld lines, in the place they were printed, so
|
|
505
|
+
// the quotation still reads in the editor's order and the omission is
|
|
506
|
+
// stated rather than left to look like an editor that said less.
|
|
507
|
+
let span = 1;
|
|
508
|
+
while (withheld.has(i + span)) span++;
|
|
509
|
+
lines.push(` ~ ${span} line(s) withheld here: the editor's licence block names the licence holder (issue #1082)`);
|
|
510
|
+
i += span - 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Silence is a finding too, and it has to be stated rather than left to look
|
|
514
|
+
// like a harness that forgot to print. The card above is what an unstated one
|
|
515
|
+
// costs.
|
|
516
|
+
if (lines.length === 0) lines.push(' the editor printed nothing on stdout or stderr');
|
|
517
|
+
return lines;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The line a rigc child REFUSED on, out of the stream it printed it on.
|
|
522
|
+
*
|
|
523
|
+
* โ ๏ธ Not `editorSaid`, which quotes both streams whole. A rigc refusal is a
|
|
524
|
+
* `UsageError` and `cli.ts` prints the entire usage under one โ some sixty lines
|
|
525
|
+
* of correct prose that would bury the one sentence somebody needs. Every
|
|
526
|
+
* refusal rigc prints starts, at column zero, with its own name and a colon:
|
|
527
|
+
* `rigc:`, `rigc check error:`, `rigc compile error:`. The usage block has
|
|
528
|
+
* neither shape โ its `rigc <command> โฆ` lines are indented, and its unindented
|
|
529
|
+
* ones carry no colon โ so none of them is mistaken for one.
|
|
530
|
+
*/
|
|
531
|
+
function refusalLines(text: string): string[] {
|
|
532
|
+
return text.split('\n').filter((line) => /^rigc\b[^\n]*:/.test(line));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* What one rigc child said, under the step that ran it.
|
|
537
|
+
*
|
|
538
|
+
* ๐จ Issue #621, and it is `editorSaid`'s defect one surface over. Step 5 was
|
|
539
|
+
* the one step that printed a child's exit code and threw its words away: on a
|
|
540
|
+
* rig whose only attachment is a `boundingbox` it reported a bare `exit=1`, and
|
|
541
|
+
* the renderer's own refusal โ *"posed no drawable attachment in any animation
|
|
542
|
+
* or in its setup pose โ there is nothing to draw"* โ reached nobody. A reader
|
|
543
|
+
* of that log cannot tell a crashed renderer from a rig with no frames, which is
|
|
544
|
+
* the same silence this file already has a judgment about.
|
|
545
|
+
*
|
|
546
|
+
* โญ The fallback is the half that keeps the fix from being the defect again one
|
|
547
|
+
* level down: a child that dies with a stack trace prints no `rigcโฆ:` line at
|
|
548
|
+
* all, so when nothing matched, the tail of stderr is quoted rather than
|
|
549
|
+
* nothing.
|
|
550
|
+
*/
|
|
551
|
+
function rigcSaid(what: string, ran: Ran): string[] {
|
|
552
|
+
const said = [
|
|
553
|
+
...refusalLines(ran.stderr),
|
|
554
|
+
// `check` reports its failures on stdout, in the FAIL lines `verdictLines`
|
|
555
|
+
// keeps for the green path.
|
|
556
|
+
...ran.stdout.split('\n').filter((line) => /^\s*FAIL/.test(line)),
|
|
557
|
+
];
|
|
558
|
+
if (said.length === 0) {
|
|
559
|
+
said.push(...ran.stderr.replace(/\s+$/, '').split('\n').slice(-6).filter((line) => line !== ''));
|
|
560
|
+
}
|
|
561
|
+
const head = ` ${what} exit=${String(ran.status)}${ran.timedOut ? ' TIMED OUT' : ''}`;
|
|
562
|
+
// Silence is a finding, for the reason `editorSaid` states: "it said nothing"
|
|
563
|
+
// and "this harness threw its words away" look identical from the outside.
|
|
564
|
+
if (said.length === 0) return [head, ' | it printed nothing on stdout or stderr'];
|
|
565
|
+
return [head, ...said.map((line) => ` | ${line.trimEnd()}`)];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The clause `rigc render` refuses on when the skeleton draws nothing, and the
|
|
570
|
+
* exit code it leaves it with.
|
|
571
|
+
*
|
|
572
|
+
* โ ๏ธ The clause is the part of that message that does not move: `cli.ts` splices
|
|
573
|
+
* ` under skin "x"` in after "setup pose" when `--skin` was passed, and ends on
|
|
574
|
+
* `โ there is nothing to draw` either way.
|
|
575
|
+
*/
|
|
576
|
+
const NOTHING_TO_DRAW = 'posed no drawable attachment in any animation or in its setup pose';
|
|
577
|
+
|
|
578
|
+
/** `cli.ts` exits 2 on a `UsageError`, which is what that refusal is. */
|
|
579
|
+
const RIGC_USAGE_EXIT = 2;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Did this `render` call refuse because there was nothing to draw?
|
|
583
|
+
*
|
|
584
|
+
* ๐ **Both signals, and the second one is why** (issue #621). The exit code
|
|
585
|
+
* alone is every `UsageError` there is โ an unknown flag, a candidate that is
|
|
586
|
+
* not there, a `--skin` the skeleton does not declare โ so reading a 2 as
|
|
587
|
+
* "nothing to draw" would turn the export dropping a skin the build declares
|
|
588
|
+
* into a SKIP, which is the one outcome this must never produce. The sentence
|
|
589
|
+
* alone is a string found on a stream however the child exited, including a path
|
|
590
|
+
* that echoed it and then did something else.
|
|
591
|
+
*
|
|
592
|
+
* โ What was rejected: reading the two skeletons here and deciding for
|
|
593
|
+
* ourselves whether either draws. That is a second implementation of
|
|
594
|
+
* `framingViewport` โ atlas resolution, the setup pose and every animation โ and
|
|
595
|
+
* two answers that need not agree is a checker agreeing with itself. The verdict
|
|
596
|
+
* belongs to the child that refused.
|
|
597
|
+
*
|
|
598
|
+
* โ Also rejected, and it is the more structural signal: an exit code of its own
|
|
599
|
+
* from `cli.ts` for this refusal. `src/render.ts` emits no code at all โ
|
|
600
|
+
* `framingViewport` returns null and `cli.ts` turns that into a `UsageError` โ so
|
|
601
|
+
* that is a change to rigc's CLI contract rather than to this harness, and it is
|
|
602
|
+
* wider than the card it would be landing under.
|
|
603
|
+
*/
|
|
604
|
+
function nothingToDraw(ran: Ran): boolean {
|
|
605
|
+
return !ran.timedOut && ran.status === RIGC_USAGE_EXIT && ran.stderr.includes(NOTHING_TO_DRAW);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Step 5's verdict when NEITHER side draws (issue #621).
|
|
610
|
+
*
|
|
611
|
+
* The question this step asks is *does what comes back still play the same*. A
|
|
612
|
+
* rig that draws nothing on both sides gives `check` nothing to compare, and the
|
|
613
|
+
* honest answer to a question with no measurement behind it is the one this file
|
|
614
|
+
* gives everywhere else: SKIP by name, never a pass and never a red. `validate`
|
|
615
|
+
* and `diff` have already measured the rig โ this is the shape #608 removed from
|
|
616
|
+
* `build`, one tool further out.
|
|
617
|
+
*/
|
|
618
|
+
const NOTHING_MEASURED =
|
|
619
|
+
'neither side draws a frame, so the check is not measured; `diff` and `validate` carry this rig';
|
|
620
|
+
|
|
621
|
+
function run(cmd: string, args: string[], timeoutS: number): Ran {
|
|
622
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutS * 1000 });
|
|
623
|
+
return {
|
|
624
|
+
status: r.status,
|
|
625
|
+
stdout: r.stdout ?? '',
|
|
626
|
+
stderr: r.stderr ?? '',
|
|
627
|
+
timedOut: r.error !== undefined && (r.error as NodeJS.ErrnoException).code === 'ETIMEDOUT',
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* How to invoke rigc: this checkout's `cli.ts` when the tool is running inside
|
|
633
|
+
* the repository, and the installed `rigc` otherwise. Named rather than guessed
|
|
634
|
+
* once, because a report that says "validate passed" has to say which binary
|
|
635
|
+
* said so.
|
|
636
|
+
*/
|
|
637
|
+
function rigcCommand(): { cmd: string; prefix: string[]; how: string } {
|
|
638
|
+
const local = join(import.meta.dir, '..', 'cli.ts');
|
|
639
|
+
if (existsSync(local)) return { cmd: process.execPath, prefix: [local], how: `${process.execPath} ${local}` };
|
|
640
|
+
return { cmd: 'rigc', prefix: [], how: 'rigc (installed)' };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What `diff` reported about the export, read out of its own JSON: every
|
|
645
|
+
* measure that is not a perfect match, named by the measure that saw it and by
|
|
646
|
+
* the block it sits in.
|
|
647
|
+
*
|
|
648
|
+
* A round trip through a correct editor moves nothing, so an empty list is the
|
|
649
|
+
* result and a populated one is the finding. Silence is not reported as a
|
|
650
|
+
* match: a report that could not be read says so.
|
|
651
|
+
*
|
|
652
|
+
* ๐จ **Every block, and that is the whole of issue #597.** This walked
|
|
653
|
+
* `sections[].measures` alone, so it read the measures that go into a section
|
|
654
|
+
* mean and nothing else โ while a `diff` report also carries each section's
|
|
655
|
+
* `nameAgnostic` comparison, its `reported` block, and since #578 a top-level
|
|
656
|
+
* `header` block holding the two stage measures. A round trip that changed or
|
|
657
|
+
* dropped the stage therefore printed *"every one a perfect match"*, which is
|
|
658
|
+
* the strongest sentence this file can print, about a question it had not
|
|
659
|
+
* asked. The stage is the sharpest case because a header field is exactly the
|
|
660
|
+
* kind of thing an editor rewrites on import and export, but it was never only
|
|
661
|
+
* the stage: `attachments.mesh_edges`, `animations.key_density` and
|
|
662
|
+
* `animations.curve_kinds` predate #578 in the same silence.
|
|
663
|
+
*
|
|
664
|
+
* โญ The headings carry `diff`'s own words rather than a paraphrase, because
|
|
665
|
+
* these measures gate nothing and a summary that let them read as failures
|
|
666
|
+
* would be inventing a verdict `diff` refuses to state. `ERT63` asserts the
|
|
667
|
+
* distinguishing clause of each heading against what `diffLines` actually
|
|
668
|
+
* prints, so a re-wording there cannot leave two documents disagreeing here.
|
|
669
|
+
*
|
|
670
|
+
* โ ๏ธ An absent `header` is a finding and not a perfect match. `DiffReport`
|
|
671
|
+
* declares that block non-optional for exactly this reason โ over an absent
|
|
672
|
+
* one the empty list is indistinguishable from one that is all 1.000 โ and
|
|
673
|
+
* this tool can reach one anyway: `rigcCommand()` falls back to the INSTALLED
|
|
674
|
+
* `rigc` when the file is not running inside the repository, and an install
|
|
675
|
+
* predating #578 writes a report with no header in it.
|
|
676
|
+
*/
|
|
677
|
+
export function diffSummaryLines(reportPath: string): string[] {
|
|
678
|
+
if (!existsSync(reportPath)) return ['(no diff report was written, so nothing was read from one)'];
|
|
679
|
+
interface Measure { id: string; what: string; matched: number; total: number; ratio: number }
|
|
680
|
+
interface Block { measures?: Measure[] }
|
|
681
|
+
interface Section { name?: string; measures?: Measure[]; nameAgnostic?: Block; reported?: Block }
|
|
682
|
+
interface Report { sections?: Section[]; header?: Block }
|
|
683
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as Report;
|
|
684
|
+
const sections = report.sections ?? [];
|
|
685
|
+
const headerMeasures = report.header?.measures ?? [];
|
|
686
|
+
// Read in the order `diff` prints them, so a row here can be found in the
|
|
687
|
+
// report it came from without translating between two orderings.
|
|
688
|
+
const blocks: Array<{ heading: string | null; measures: Measure[] }> = [
|
|
689
|
+
{ heading: null, measures: sections.flatMap((s) => s.measures ?? []) },
|
|
690
|
+
{
|
|
691
|
+
heading: ' name-agnostic โ the same two skeletons compared with names thrown away',
|
|
692
|
+
measures: sections.flatMap((s) => s.nameAgnostic?.measures ?? []),
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
heading: ' reported โ unobservable from the frames, so reported and folded into nothing',
|
|
696
|
+
measures: [...sections.flatMap((s) => s.reported?.measures ?? []), ...headerMeasures],
|
|
697
|
+
},
|
|
698
|
+
];
|
|
699
|
+
const measured = blocks.reduce((n, block) => n + block.measures.length, 0);
|
|
700
|
+
if (measured === 0) return ['(the diff report carried no measures โ read it before believing this run)'];
|
|
701
|
+
|
|
702
|
+
const lines: string[] = [];
|
|
703
|
+
for (const block of blocks) {
|
|
704
|
+
const moved = block.measures.filter((m) => m.ratio !== 1);
|
|
705
|
+
if (moved.length === 0) continue;
|
|
706
|
+
if (block.heading !== null) lines.push(block.heading);
|
|
707
|
+
const indent = block.heading === null ? ' ' : ' ';
|
|
708
|
+
for (const m of moved) {
|
|
709
|
+
lines.push(`${indent}moved ${m.id} ${m.matched}/${m.total} (${(m.ratio * 100).toFixed(2)}%) โ ${m.what}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (headerMeasures.length === 0) {
|
|
713
|
+
lines.push(
|
|
714
|
+
' the report carries no `skeleton` header block, so the header\'s box was never compared โ the `rigc` that wrote ' +
|
|
715
|
+
'it predates the header measures (issue #578), and an absent block must not read here like one that is ' +
|
|
716
|
+
'all 1.000',
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (lines.length > 0) return lines;
|
|
720
|
+
// The counts are derived from the same three walks the rows come from, so the
|
|
721
|
+
// one sentence that claims everything held names how much everything was.
|
|
722
|
+
const [gating, agnostic, reported] = blocks.map((block) => block.measures.length);
|
|
723
|
+
return [
|
|
724
|
+
` ${measured} measure(s) โ ${gating} in the sections, ${agnostic} name-agnostic, ${reported} reported ` +
|
|
725
|
+
'and never gating โ every one a perfect match',
|
|
726
|
+
];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* `check`'s per-animation figures: how far the export's drawing moved from the
|
|
731
|
+
* build's own frames.
|
|
732
|
+
*
|
|
733
|
+
* `meanMae` is the headline; `worstDrift` is the one that catches a single slot
|
|
734
|
+
* in a single frame, which a mean over a whole animation hides.
|
|
735
|
+
*/
|
|
736
|
+
function checkFigures(reportPath: string): string[] {
|
|
737
|
+
if (!existsSync(reportPath)) return ['(no check report was written, so nothing was read from one)'];
|
|
738
|
+
interface Anim {
|
|
739
|
+
/**
|
|
740
|
+
* `null` on the row `check` writes for the SETUP pose (`dir: "setup"`) โ which
|
|
741
|
+
* is the only row a rig with no animations gets. Read as a string, that report
|
|
742
|
+
* threw at step 5 on every animation-less build (issue #796's two editor fixtures).
|
|
743
|
+
*/
|
|
744
|
+
animation: string | null;
|
|
745
|
+
dir?: string;
|
|
746
|
+
compared: number;
|
|
747
|
+
meanMae: number;
|
|
748
|
+
worstMae: number;
|
|
749
|
+
worstDrift: number;
|
|
750
|
+
worstDriftSlot: string | null;
|
|
751
|
+
worstDriftFrame: number;
|
|
752
|
+
}
|
|
753
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { animations?: Anim[] };
|
|
754
|
+
const animations = report.animations ?? [];
|
|
755
|
+
if (animations.length === 0) return ['(the check report carried no animations โ read it before believing this run)'];
|
|
756
|
+
return animations.map(
|
|
757
|
+
(a) =>
|
|
758
|
+
` ${(a.animation ?? `(${a.dir ?? 'setup'})`).padEnd(12)} ${a.compared} frame(s) mean MAE ${a.meanMae.toFixed(4)} worst ${a.worstMae.toFixed(4)}` +
|
|
759
|
+
` worst drift ${a.worstDrift.toFixed(3)}px${a.worstDriftSlot ? ` on ${a.worstDriftSlot} @ f${a.worstDriftFrame}` : ''}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The largest per-animation `meanMae` in one check report, or `null` when there
|
|
765
|
+
* is no report to read one from.
|
|
766
|
+
*
|
|
767
|
+
* โ ๏ธ `check`'s exit code is not a verdict โ there is no pass mark in it, by
|
|
768
|
+
* design, any more than there is one in `diff` โ so a per-skin roll-up built on
|
|
769
|
+
* exit codes alone reports every skin as fine and reports it in the column a
|
|
770
|
+
* reader looks at. This is the figure that actually moves when one skin's art
|
|
771
|
+
* comes back wrong, which is what makes the roll-up a roll-up (issue #571).
|
|
772
|
+
*/
|
|
773
|
+
function worstMeanMae(reportPath: string): number | null {
|
|
774
|
+
if (!existsSync(reportPath)) return null;
|
|
775
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { animations?: Array<{ meanMae?: number }> };
|
|
776
|
+
const values = (report.animations ?? []).map((a) => a.meanMae).filter((v): v is number => typeof v === 'number');
|
|
777
|
+
return values.length === 0 ? null : Math.max(...values);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
// step 5's per-skin plan (issue #571)
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
//
|
|
784
|
+
// ๐จ Step 5 used to render and check ONCE, with no skin, which draws the default
|
|
785
|
+
// skin and nothing else. On a rig whose named skins carry the contested art that
|
|
786
|
+
// is a comparison of blank against blank: the ninth round trip read `check`
|
|
787
|
+
// 0.0000 on a rig where the construct under test lives in a named skin, and the
|
|
788
|
+
// zero was true and empty at once. So the plan below is one block PER DECLARED
|
|
789
|
+
// SKIN, and a skin whose art the editor moved is a red row with its own name on
|
|
790
|
+
// it rather than a figure nobody rendered.
|
|
791
|
+
//
|
|
792
|
+
// โญ Split out as pure functions for the reason `shapeOf`/`shapeDiff` are: the
|
|
793
|
+
// `ERT` suite has no editor and never will, and a loop that can only be read by
|
|
794
|
+
// driving one is a loop nobody has seen work.
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The skins a skeleton file declares, in the order the file lists them.
|
|
798
|
+
*
|
|
799
|
+
* โ ๏ธ Off the FILE rather than off `spine-core`: this is the build's own
|
|
800
|
+
* `skeleton.json`, read before any of it is loaded, and the tool's other summary
|
|
801
|
+
* (`shapeOf`) reads the same file the same way. A skeleton with no `skins` array
|
|
802
|
+
* at all comes back empty, which is a different fact from `["default"]` and is
|
|
803
|
+
* carried as one โ see `skinBlocks`.
|
|
804
|
+
*/
|
|
805
|
+
export function skinsDeclaredBy(path: string): string[] {
|
|
806
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { skins?: Array<{ name?: unknown }> };
|
|
807
|
+
const out: string[] = [];
|
|
808
|
+
for (const skin of parsed.skins ?? []) {
|
|
809
|
+
// A skin with no usable name is counted under one rather than dropped: a
|
|
810
|
+
// block that vanished would be a skin nobody rendered and nobody missed.
|
|
811
|
+
out.push(typeof skin?.name === 'string' && skin.name !== '' ? skin.name : '(unnamed)');
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* How one step-5 block ended (issue #621).
|
|
818
|
+
*
|
|
819
|
+
* Three states rather than an exit code, because `check` not having run and
|
|
820
|
+
* `check` having returned 0 are different facts and used to print the same:
|
|
821
|
+
* `measured` is the only one that carries a figure, and `not measured` is the
|
|
822
|
+
* only one that does not fail the run.
|
|
823
|
+
*/
|
|
824
|
+
export type BlockVerdict = 'measured' | 'not measured' | 'no frames';
|
|
825
|
+
|
|
826
|
+
/** One render-and-check block of step 5: which skin, where its frames go. */
|
|
827
|
+
export interface SkinBlock {
|
|
828
|
+
/** The skin this block poses under โ `null` when the skeleton declares none. */
|
|
829
|
+
skin: string | null;
|
|
830
|
+
/** The heading printed above the block, which is where the skin's name is read. */
|
|
831
|
+
heading: string;
|
|
832
|
+
/** `--skin <name>`, or nothing at all when there is no skin to name. */
|
|
833
|
+
args: string[];
|
|
834
|
+
buildFrames: string;
|
|
835
|
+
exportFrames: string;
|
|
836
|
+
checkJson: string;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* A directory name for one skin's frames: its index, then what of its name is a
|
|
841
|
+
* filename.
|
|
842
|
+
*
|
|
843
|
+
* The index leads because **a skin name is not a path**. The Spine editor writes
|
|
844
|
+
* folders into skin names with `/`, so `goblins/green` would land two levels
|
|
845
|
+
* down or collide with a sibling, and any scheme that replaces the offending
|
|
846
|
+
* characters can map two distinct skins onto one directory. The index cannot
|
|
847
|
+
* collide, and it is the skeleton's own ordering rather than a number invented
|
|
848
|
+
* here.
|
|
849
|
+
*/
|
|
850
|
+
function skinDirName(name: string, index: number): string {
|
|
851
|
+
return `${index}-${name.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Step 5's plan: one block per declared skin, or exactly one block when the
|
|
856
|
+
* skeleton declares no skin at all.
|
|
857
|
+
*
|
|
858
|
+
* โญ The no-skin case keeps the old paths (`render-build`, `render-export`,
|
|
859
|
+
* `check.json`) because for such a skeleton there is nothing to distinguish, and
|
|
860
|
+
* a run whose output moved would be a run whose ledgers all have to be re-read
|
|
861
|
+
* for no measurement gained. A skinned skeleton files each block under its own
|
|
862
|
+
* directory, so the frames of two skins can never overwrite each other โ which
|
|
863
|
+
* is the failure that would turn "one block per skin" back into one block.
|
|
864
|
+
*/
|
|
865
|
+
export function skinBlocks(skins: string[], out: string, fps: number): SkinBlock[] {
|
|
866
|
+
if (skins.length === 0) {
|
|
867
|
+
return [
|
|
868
|
+
{
|
|
869
|
+
skin: null,
|
|
870
|
+
heading: `## 5 render both @${fps}fps, check the export against the build's own frames (no skin declared)`,
|
|
871
|
+
args: [],
|
|
872
|
+
buildFrames: join(out, 'render-build'),
|
|
873
|
+
exportFrames: join(out, 'render-export'),
|
|
874
|
+
checkJson: join(out, 'check.json'),
|
|
875
|
+
},
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
return skins.map((skin, i) => ({
|
|
879
|
+
skin,
|
|
880
|
+
heading:
|
|
881
|
+
`## 5.${i + 1}/${skins.length} skin "${skin}" โ render both @${fps}fps, check the export against the ` +
|
|
882
|
+
"build's own frames",
|
|
883
|
+
args: ['--skin', skin],
|
|
884
|
+
buildFrames: join(out, 'render-build', skinDirName(skin, i)),
|
|
885
|
+
exportFrames: join(out, 'render-export', skinDirName(skin, i)),
|
|
886
|
+
checkJson: join(out, `check-${skinDirName(skin, i)}.json`),
|
|
887
|
+
}));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* The two sides' skin rosters, split into the skins both declare and the ones
|
|
892
|
+
* only one side does (issue #801).
|
|
893
|
+
*
|
|
894
|
+
* ๐จ A skin one side does not declare is the finding, not a render. Before this,
|
|
895
|
+
* step 5 planned its blocks off the build alone and asked the export for a skin
|
|
896
|
+
* it no longer had: the card's rig โ `default` empty, two named skins โ came
|
|
897
|
+
* back from 4.3.26 with `skins: [alt, base]`, and step 5 then printed two
|
|
898
|
+
* DIFFERENT refusals for the one absent skin (`no skin "default" in this
|
|
899
|
+
* skeleton` from the export's side, `posed no drawable attachment` from the
|
|
900
|
+
* build's), so the skin read red for a reason neither line named. A render of a
|
|
901
|
+
* skin that is not there measures nothing on either side, so a skin only one
|
|
902
|
+
* side declares is named here, by name, and is rendered on neither.
|
|
903
|
+
*
|
|
904
|
+
* Both directions, because they are the same fact about a round trip: a skin
|
|
905
|
+
* the export ADDS is as much a divergence as one it drops.
|
|
906
|
+
*/
|
|
907
|
+
export function skinRoster(
|
|
908
|
+
build: string[],
|
|
909
|
+
exported: string[],
|
|
910
|
+
): { both: string[]; lostByExport: string[]; addedByExport: string[] } {
|
|
911
|
+
const inExport = new Set(exported);
|
|
912
|
+
const inBuild = new Set(build);
|
|
913
|
+
return {
|
|
914
|
+
both: build.filter((skin) => inExport.has(skin)),
|
|
915
|
+
lostByExport: build.filter((skin) => !inExport.has(skin)),
|
|
916
|
+
addedByExport: exported.filter((skin) => !inBuild.has(skin)),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* One measure of a `diff --json` report as `matched/total (ratio)`, or `null`
|
|
922
|
+
* when the report or the measure is not there โ read off the report for the
|
|
923
|
+
* reason `diffSummaryLines` is.
|
|
924
|
+
*/
|
|
925
|
+
function diffFigure(reportPath: string, id: string): string | null {
|
|
926
|
+
if (!existsSync(reportPath)) return null;
|
|
927
|
+
interface Measure { id: string; matched: number; total: number; ratio: number }
|
|
928
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { sections?: Array<{ measures?: Measure[] }> };
|
|
929
|
+
const found = (report.sections ?? []).flatMap((s) => s.measures ?? []).find((m) => m.id === id);
|
|
930
|
+
return found === undefined ? null : `${found.matched}/${found.total} (${found.ratio.toFixed(3)})`;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** The shape of a skeleton file, for the field-by-field comparison. */
|
|
934
|
+
export interface Shape {
|
|
935
|
+
spine: string;
|
|
936
|
+
bones: number;
|
|
937
|
+
slots: number;
|
|
938
|
+
attachments: number;
|
|
939
|
+
constraints: Record<string, number>;
|
|
940
|
+
animations: string[];
|
|
941
|
+
timelineKinds: string[];
|
|
942
|
+
images: string | null;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* How many constraints of each `type`, read out of 4.3's single array.
|
|
947
|
+
*
|
|
948
|
+
* ๐จ This used to count four FIXED keys off the 4.1-era top-level arrays โ
|
|
949
|
+
* `d.ik`, `d.transform`, `d.path`, `d.physics` โ none of which a 4.3 file has
|
|
950
|
+
* (issue #561). Every row therefore read `0 -> 0` on every trip this tool has
|
|
951
|
+
* ever run, so the summary's answer to *"did the editor drop a constraint"* was
|
|
952
|
+
* a constant, printed with the same confidence as the rows that measure
|
|
953
|
+
* something. Measured on `round6/out/pathmodes/build/skeleton.json`, whose
|
|
954
|
+
* top-level keys are `skeleton, bones, slots, skins, animations, constraints`:
|
|
955
|
+
* the old reads returned `{ik: 0, transform: 0, path: 0, physics: 0}` beside one
|
|
956
|
+
* path constraint named `ride`.
|
|
957
|
+
*
|
|
958
|
+
* โญ The keys are now the types actually **present**, which is why `shapeDiff`
|
|
959
|
+
* unions them: a type that vanishes has a key on one side only, and a fixed key
|
|
960
|
+
* list would have to be kept by hand against a format that added `slider` in
|
|
961
|
+
* 4.3 and can add another.
|
|
962
|
+
*/
|
|
963
|
+
function constraintsByType(list: ReadonlyArray<{ type?: unknown }>): Record<string, number> {
|
|
964
|
+
const counts: Record<string, number> = {};
|
|
965
|
+
for (const c of list) {
|
|
966
|
+
// A constraint with no usable `type` is what `A01` exists to catch, so it is
|
|
967
|
+
// counted under a name rather than dropped โ a row nobody can read beats a
|
|
968
|
+
// row nobody gets.
|
|
969
|
+
const type = typeof c?.type === 'string' && c.type !== '' ? c.type : '(no type)';
|
|
970
|
+
counts[type] = (counts[type] ?? 0) + 1;
|
|
971
|
+
}
|
|
972
|
+
return counts;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function shapeOf(path: string): Shape {
|
|
976
|
+
interface Skel {
|
|
977
|
+
skeleton?: { spine?: string; images?: string };
|
|
978
|
+
bones?: unknown[];
|
|
979
|
+
slots?: unknown[];
|
|
980
|
+
skins?: Array<{ attachments?: Record<string, Record<string, unknown>> }>;
|
|
981
|
+
constraints?: Array<{ type?: unknown }>;
|
|
982
|
+
animations?: Record<string, Record<string, unknown>>;
|
|
983
|
+
}
|
|
984
|
+
const d = JSON.parse(readFileSync(path, 'utf8')) as Skel;
|
|
985
|
+
const kinds = new Set<string>();
|
|
986
|
+
for (const anim of Object.values(d.animations ?? {})) for (const k of Object.keys(anim)) kinds.add(k);
|
|
987
|
+
return {
|
|
988
|
+
spine: d.skeleton?.spine ?? '(none)',
|
|
989
|
+
bones: (d.bones ?? []).length,
|
|
990
|
+
slots: (d.slots ?? []).length,
|
|
991
|
+
attachments: (d.skins ?? []).reduce(
|
|
992
|
+
(n, s) => n + Object.values(s.attachments ?? {}).reduce((m, v) => m + Object.keys(v).length, 0),
|
|
993
|
+
0,
|
|
994
|
+
),
|
|
995
|
+
constraints: constraintsByType(d.constraints ?? []),
|
|
996
|
+
animations: Object.keys(d.animations ?? {}).sort(),
|
|
997
|
+
timelineKinds: [...kinds].sort(),
|
|
998
|
+
images: d.skeleton?.images ?? null,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** The rows where the two shapes disagree โ what the editor rewrote. */
|
|
1003
|
+
export function shapeDiff(before: Shape, after: Shape): string[] {
|
|
1004
|
+
const rows: string[] = [];
|
|
1005
|
+
const cmp = (field: string, a: unknown, b: unknown): void => {
|
|
1006
|
+
const x = JSON.stringify(a);
|
|
1007
|
+
const y = JSON.stringify(b);
|
|
1008
|
+
if (x !== y) rows.push(`${field}: build ${x} -> export ${y}`);
|
|
1009
|
+
};
|
|
1010
|
+
cmp('skeleton.spine', before.spine, after.spine);
|
|
1011
|
+
cmp('skeleton.images', before.images, after.images);
|
|
1012
|
+
cmp('bones', before.bones, after.bones);
|
|
1013
|
+
cmp('slots', before.slots, after.slots);
|
|
1014
|
+
cmp('attachments', before.attachments, after.attachments);
|
|
1015
|
+
// The UNION of both sides' types, not the build's: a constraint type the build
|
|
1016
|
+
// has and the export does not is the case this row exists for, and iterating
|
|
1017
|
+
// one side's keys would also miss a type only the export carries. Absent reads
|
|
1018
|
+
// as 0 so the row says `build 1 -> export 0` rather than naming `undefined`.
|
|
1019
|
+
for (const k of [...new Set([...Object.keys(before.constraints), ...Object.keys(after.constraints)])].sort()) {
|
|
1020
|
+
cmp(`${k} constraints`, before.constraints[k] ?? 0, after.constraints[k] ?? 0);
|
|
1021
|
+
}
|
|
1022
|
+
cmp('animations', before.animations, after.animations);
|
|
1023
|
+
cmp('timeline kinds', before.timelineKinds, after.timelineKinds);
|
|
1024
|
+
return rows;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** What a JSON value is, in the words a refusal names it by: `null`, `an array`, `a number`, โฆ */
|
|
1028
|
+
function jsonKind(value: unknown): string {
|
|
1029
|
+
if (value === null) return 'null';
|
|
1030
|
+
if (Array.isArray(value)) return 'an array';
|
|
1031
|
+
return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** True for the one JSON kind a skeleton file and each of its records is: a plain object. */
|
|
1035
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1036
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* The build's `skeleton.json`, read and parsed once, or the sentence the tool
|
|
1041
|
+
* refuses it with (issue #1090).
|
|
1042
|
+
*
|
|
1043
|
+
* ๐จ The `skeleton.images` probe in `main` used to be the first thing that read
|
|
1044
|
+
* this file, and it read it with a bare `JSON.parse`. A file that was not JSON,
|
|
1045
|
+
* or was JSON holding `null`, therefore left the tool as an uncaught
|
|
1046
|
+
* `SyntaxError` / `TypeError` and its stack โ before the editor, so with nothing
|
|
1047
|
+
* of the editor's to quote, and in a shape no other refusal in this file has.
|
|
1048
|
+
* And a file holding `[]` or `7` did not stop at all: the probe found no
|
|
1049
|
+
* `skeleton.images` on it and the run went on to hand the editor a file that is
|
|
1050
|
+
* not a skeleton, minutes of import for a refusal this could state for free.
|
|
1051
|
+
*
|
|
1052
|
+
* โญ So this is the one reader of the build's skeleton text before step 1, and
|
|
1053
|
+
* it refuses by the file's path and what it found there: a directory, the
|
|
1054
|
+
* parser's own message, or the JSON kind that is not an object. What it hands
|
|
1055
|
+
* back is the value the probe reads, so a readable build is parsed exactly as
|
|
1056
|
+
* it was and every line after this prints as it did.
|
|
1057
|
+
*/
|
|
1058
|
+
function readBuildSkeleton(source: string): { skeleton: Record<string, unknown> } | { refusal: string } {
|
|
1059
|
+
return readSkeletonObject(
|
|
1060
|
+
source,
|
|
1061
|
+
"the build's skeleton.json",
|
|
1062
|
+
'The round trip imports that file into the editor, so it has to be one skeleton JSON object โ `skeleton`, ' +
|
|
1063
|
+
'`bones`, `slots`, โฆ โ which is what `rigc build` writes there.',
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* One skeleton file read and parsed, with its bytes โ or the sentence the tool
|
|
1069
|
+
* refuses it with, naming `what` it is, its path, what was found there and the
|
|
1070
|
+
* `required` sentence. The one body behind `readBuildSkeleton` and the
|
|
1071
|
+
* `--exported` read (issue #1107), so the two files are refused in one shape.
|
|
1072
|
+
*
|
|
1073
|
+
* The bytes are handed back because the `--exported` file is written into the
|
|
1074
|
+
* candidate from them: the file step 3 measures is then the file this parsed,
|
|
1075
|
+
* byte for byte, rather than whatever is at the path by the time step 3 runs.
|
|
1076
|
+
*/
|
|
1077
|
+
function readSkeletonObject(
|
|
1078
|
+
path: string,
|
|
1079
|
+
what: string,
|
|
1080
|
+
required: string,
|
|
1081
|
+
): { skeleton: Record<string, unknown>; bytes: Buffer } | { refusal: string } {
|
|
1082
|
+
let bytes: Buffer;
|
|
1083
|
+
try {
|
|
1084
|
+
bytes = readFileSync(path);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
1087
|
+
if (code === 'EISDIR') return { refusal: `${what} at ${path} is a directory, not a file. ${required}` };
|
|
1088
|
+
return { refusal: `${what} at ${path} could not be read (${code ?? String(err)}). ${required}` };
|
|
1089
|
+
}
|
|
1090
|
+
const text = bytes.toString('utf8');
|
|
1091
|
+
if (text.trim() === '') {
|
|
1092
|
+
return { refusal: `${what} at ${path} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
|
|
1093
|
+
}
|
|
1094
|
+
let parsed: unknown;
|
|
1095
|
+
try {
|
|
1096
|
+
parsed = JSON.parse(text);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
const said = err instanceof Error ? err.message : String(err);
|
|
1099
|
+
return { refusal: `${what} at ${path} is not JSON โ the parser stopped with "${said}". ${required}` };
|
|
1100
|
+
}
|
|
1101
|
+
if (!isRecord(parsed)) {
|
|
1102
|
+
return { refusal: `${what} at ${path} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
|
|
1103
|
+
}
|
|
1104
|
+
return { skeleton: parsed, bytes };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* The build's atlas, read once before step 1, or the sentence the tool refuses
|
|
1109
|
+
* it with (issue #1107): its name and the page names it lists.
|
|
1110
|
+
*
|
|
1111
|
+
* ๐จ This read used to sit at step 3, after the editor had imported and
|
|
1112
|
+
* exported. So a build with no atlas was refused by name only after a licensed
|
|
1113
|
+
* editor had spent an import and an export on it, and a directory named
|
|
1114
|
+
* `*.atlas` beside the build threw `EISDIR` there as a stack. Neither needs the
|
|
1115
|
+
* editor to answer: the atlas is rigc's input to step 3, not the editor's.
|
|
1116
|
+
*
|
|
1117
|
+
* โญ Every `*.atlas` entry is read, not only the one whose pages are listed,
|
|
1118
|
+
* because step 3 copies every one of them beside the export, and a directory
|
|
1119
|
+
* among them throws at that copy. The first entry is the atlas, as it always was.
|
|
1120
|
+
*
|
|
1121
|
+
* โ ๏ธ "Read" is all this can mean. The tool's own reader of an atlas is the line
|
|
1122
|
+
* filter below, which accepts any text, so there is no malformed atlas for it to
|
|
1123
|
+
* refuse; what the atlas SAYS is the gate's to judge at step 3 (`A08`, `A17`).
|
|
1124
|
+
*/
|
|
1125
|
+
function readBuildAtlas(build: string): { pageNames: string[] } | { refusal: string } {
|
|
1126
|
+
const atlases = readdirSync(build).filter((f) => f.endsWith('.atlas'));
|
|
1127
|
+
if (atlases.length === 0) return { refusal: `no .atlas in the build directory ${build}` };
|
|
1128
|
+
const required =
|
|
1129
|
+
'Step 3 copies the build\'s atlas and pages beside the export so the export can be loaded and measured, so ' +
|
|
1130
|
+
'every `.atlas` there has to be a file โ the one `rigc build` writes.';
|
|
1131
|
+
let first: string | null = null;
|
|
1132
|
+
for (const name of atlases) {
|
|
1133
|
+
const path = join(build, name);
|
|
1134
|
+
try {
|
|
1135
|
+
const text = readFileSync(path, 'utf8');
|
|
1136
|
+
if (first === null) first = text;
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
1139
|
+
if (code === 'EISDIR') return { refusal: `the build's atlas at ${path} is a directory, not a file. ${required}` };
|
|
1140
|
+
return { refusal: `the build's atlas at ${path} could not be read (${code ?? String(err)}). ${required}` };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const pageNames = (first ?? '')
|
|
1144
|
+
.split('\n')
|
|
1145
|
+
.filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
|
|
1146
|
+
.map((line) => line.trim());
|
|
1147
|
+
return { pageNames };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* The field of the build's skeleton the images probe would have thrown on, as a
|
|
1152
|
+
* sentence naming it and what it holds โ or null when every field the probe
|
|
1153
|
+
* dereferences is the kind it reads (issue #1090).
|
|
1154
|
+
*
|
|
1155
|
+
* โ ๏ธ Only those fields, and only on the path that reads them: the walk below
|
|
1156
|
+
* runs when `skeleton.images` is declared, and a field this does not read is the
|
|
1157
|
+
* gate's to judge after the import, not a precondition of the editor's. Asking
|
|
1158
|
+
* more here would be a second validator in front of the first.
|
|
1159
|
+
*/
|
|
1160
|
+
function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
|
|
1161
|
+
const head = skeleton.skeleton;
|
|
1162
|
+
if (!isRecord(head)) return null;
|
|
1163
|
+
const images = head.images;
|
|
1164
|
+
if (images === undefined) return null;
|
|
1165
|
+
if (typeof images !== 'string') return `\`skeleton.images\` holds ${jsonKind(images)}, not a path string`;
|
|
1166
|
+
if (images === '') return null;
|
|
1167
|
+
const skins = skeleton.skins;
|
|
1168
|
+
if (skins === undefined) return null;
|
|
1169
|
+
if (!Array.isArray(skins)) return `\`skins\` holds ${jsonKind(skins)}, not an array`;
|
|
1170
|
+
for (let i = 0; i < skins.length; i++) {
|
|
1171
|
+
const skin: unknown = skins[i];
|
|
1172
|
+
if (!isRecord(skin)) return `\`skins[${i}]\` holds ${jsonKind(skin)}, not an object`;
|
|
1173
|
+
const attachments = skin.attachments;
|
|
1174
|
+
if (attachments === undefined) continue;
|
|
1175
|
+
if (!isRecord(attachments)) return `\`skins[${i}].attachments\` holds ${jsonKind(attachments)}, not an object`;
|
|
1176
|
+
for (const [slot, entries] of Object.entries(attachments)) {
|
|
1177
|
+
if (!isRecord(entries)) return `\`skins[${i}].attachments.${slot}\` holds ${jsonKind(entries)}, not an object`;
|
|
1178
|
+
for (const [placeholder, att] of Object.entries(entries)) {
|
|
1179
|
+
if (!isRecord(att)) {
|
|
1180
|
+
return `\`skins[${i}].attachments.${slot}.${placeholder}\` holds ${jsonKind(att)}, not an object`;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function main(): void {
|
|
1189
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
1190
|
+
const rigc = rigcCommand();
|
|
1191
|
+
|
|
1192
|
+
const source = join(opts.build, 'skeleton.json');
|
|
1193
|
+
const log: string[] = [];
|
|
1194
|
+
const emit = (line: string): void => {
|
|
1195
|
+
console.log(line);
|
|
1196
|
+
log.push(line);
|
|
1197
|
+
};
|
|
1198
|
+
const logPath = join(opts.out, 'roundtrip.log');
|
|
1199
|
+
/**
|
|
1200
|
+
* Write what the run has said so far, wherever it stops.
|
|
1201
|
+
*
|
|
1202
|
+
* โ ๏ธ `roundtrip.log` used to be written on the last line of `main`, so a run
|
|
1203
|
+
* that REFUSED wrote none โ and issue #541's card cites the log as the place to
|
|
1204
|
+
* read the editor's output, which on a failed import was a file that did not
|
|
1205
|
+
* exist. A record kept only for the runs that went well is not a record.
|
|
1206
|
+
*/
|
|
1207
|
+
const keepLog = (): void => {
|
|
1208
|
+
// Nothing said, nothing to keep: the refusals that fire before the first
|
|
1209
|
+
// `emit` (no build directory) would otherwise leave an empty file and a
|
|
1210
|
+
// directory the run never used.
|
|
1211
|
+
if (log.length === 0) return;
|
|
1212
|
+
try {
|
|
1213
|
+
mkdirSync(opts.out, { recursive: true });
|
|
1214
|
+
writeFileSync(logPath, `${log.join('\n')}\n`);
|
|
1215
|
+
} catch {
|
|
1216
|
+
// A log this cannot write is not worth failing a refusal over; the same
|
|
1217
|
+
// lines already went to stdout.
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
const fail = (message: string): never => {
|
|
1221
|
+
keepLog();
|
|
1222
|
+
console.error(`rigc editor_roundtrip: ${message}`);
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
|
|
1227
|
+
// ๐ Read and parsed HERE, once, before anything else reads it (issue #1090):
|
|
1228
|
+
// a build whose skeleton is not one JSON object is refused by name rather than
|
|
1229
|
+
// thrown as a stack by the probe below, or handed to the editor.
|
|
1230
|
+
const read = readBuildSkeleton(source);
|
|
1231
|
+
if ('refusal' in read) return fail(read.refusal);
|
|
1232
|
+
const probeFault = imagesProbeFault(read.skeleton);
|
|
1233
|
+
if (probeFault !== null) {
|
|
1234
|
+
fail(
|
|
1235
|
+
`the build's skeleton.json at ${source} is an object, but ${probeFault} โ one of the fields read before ` +
|
|
1236
|
+
"step 1 to check that every image the editor's import will look for is there. A skeleton `rigc build` " +
|
|
1237
|
+
'wrote never carries that; rebuild the directory with `rigc build โฆ --copy-images`.',
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ๐จ The art the EDITOR will look for, checked before the editor is started โ
|
|
1242
|
+
// issue #562. The editor's JSON import reads `skeleton.images` and finds each
|
|
1243
|
+
// attachment's file by name under it; it never reads an atlas (#370, measured
|
|
1244
|
+
// at the MISSING wall). So a build whose `images` no longer resolves imports
|
|
1245
|
+
// as a skeleton with no pixels, and the run goes on to gate, `diff`, render
|
|
1246
|
+
// and `check` a candidate whose every region is blank โ a green-looking
|
|
1247
|
+
// measurement of nothing, or a red one blaming the editor.
|
|
1248
|
+
//
|
|
1249
|
+
// โ ๏ธ This is a `--pack` build's ordinary shape, not a corner case. `--pack`
|
|
1250
|
+
// writes ONE shared page into `--out` and no loose parts at all (measured:
|
|
1251
|
+
// `round6/out/packed/build/` holds `skeleton.json`, `skeleton.atlas`,
|
|
1252
|
+
// `skeleton.png` and nothing else), so `skeletonImagesPath` falls through to
|
|
1253
|
+
// the loose parts directory and `images` necessarily points OUT of the build
|
|
1254
|
+
// โ `"../../../rigs/packed/parts/"` on that run. The build is self-contained
|
|
1255
|
+
// for a runtime and is not for the editor, and the two directories go their
|
|
1256
|
+
// separate ways the moment anybody moves either.
|
|
1257
|
+
//
|
|
1258
|
+
// ๐ Why this refuses rather than pointing `images` inside `--out`: there is
|
|
1259
|
+
// nothing in there to point AT. The editor would look for `crown.png` beside
|
|
1260
|
+
// the skeleton and find one packed page, so the "fix" turns a path that
|
|
1261
|
+
// resolves while the parts are in place into one that can never resolve at
|
|
1262
|
+
// all. What the editor needs is loose files, which is what `--copy-images`
|
|
1263
|
+
// makes โ and `--pack --copy-images` is refused by `cli.ts`, correctly,
|
|
1264
|
+
// because a packed atlas does not reference loose parts.
|
|
1265
|
+
//
|
|
1266
|
+
// โ It is checked HERE, before step 1, because it matters to the editor's
|
|
1267
|
+
// import in step 1. The atlas and an `--exported` file are read before step 1
|
|
1268
|
+
// as well, just below, although nothing before step 3 uses them (issue #1107):
|
|
1269
|
+
// they are rigc's inputs and not the editor's, so reading them first costs the
|
|
1270
|
+
// editor nothing and refuses before a licensed editor is started on a trip
|
|
1271
|
+
// that cannot finish โ the reason `readBuildSkeleton` reads the build's
|
|
1272
|
+
// skeleton first (#1090). So the order is: every input rigc reads is read
|
|
1273
|
+
// before the editor starts, and only what the editor writes is read after it.
|
|
1274
|
+
{
|
|
1275
|
+
interface ImagesProbe {
|
|
1276
|
+
skeleton?: { images?: string };
|
|
1277
|
+
skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
|
|
1278
|
+
}
|
|
1279
|
+
// The value `readBuildSkeleton` parsed, and the fields this walk reads are
|
|
1280
|
+
// the ones `imagesProbeFault` has already held to their kinds.
|
|
1281
|
+
const probe = read.skeleton as ImagesProbe;
|
|
1282
|
+
const declared = probe.skeleton?.images;
|
|
1283
|
+
if (declared !== undefined && declared !== '') {
|
|
1284
|
+
const imagesDir = resolve(opts.build, declared);
|
|
1285
|
+
// Only the two attachment types that read a texture. A boundingbox,
|
|
1286
|
+
// clipping or path attachment names no image and would be a false
|
|
1287
|
+
// refusal โ `src/types.ts` says so for each of them.
|
|
1288
|
+
const wanted = new Set<string>();
|
|
1289
|
+
for (const skin of probe.skins ?? []) {
|
|
1290
|
+
for (const slot of Object.values(skin.attachments ?? {})) {
|
|
1291
|
+
for (const [placeholder, att] of Object.entries(slot)) {
|
|
1292
|
+
if (att.type !== undefined && att.type !== 'mesh') continue;
|
|
1293
|
+
// The parser's own defaults: `path`, else the attachment's `name`,
|
|
1294
|
+
// else its placeholder (`SkeletonJson.js:526`, `:529`, `:560`) โ a
|
|
1295
|
+
// stated `name` is the file the editor looks for (issue #796).
|
|
1296
|
+
wanted.add(att.path ?? att.name ?? placeholder);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
|
1301
|
+
const missing = [...wanted]
|
|
1302
|
+
.sort()
|
|
1303
|
+
.filter((name) => !EXTENSIONS.some((ext) => existsSync(join(imagesDir, `${name}${ext}`))));
|
|
1304
|
+
if (!existsSync(imagesDir)) {
|
|
1305
|
+
fail(
|
|
1306
|
+
`the build's skeleton.images is "${declared}", which resolves to ${imagesDir} โ and there is no ` +
|
|
1307
|
+
'directory there. The editor finds a JSON import\'s art by that path and never by the atlas, so the ' +
|
|
1308
|
+
'import would produce a skeleton with no pixels and every measurement after it would be of nothing. ' +
|
|
1309
|
+
'A `--pack` build is a runtime artifact: its pages are in --out and its `images` names the loose parts ' +
|
|
1310
|
+
'it was packed from, so it round-trips only while those parts are where they were at build time. ' +
|
|
1311
|
+
'Rebuild with `--copy-images` (which puts the parts beside the skeleton), or put that directory back.',
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
if (missing.length > 0) {
|
|
1315
|
+
fail(
|
|
1316
|
+
`the build's skeleton.images is "${declared}" (${imagesDir}) and ${missing.length} of ${wanted.size} ` +
|
|
1317
|
+
`attachment image(s) are not under it โ the first is "${missing[0]}". The editor resolves each ` +
|
|
1318
|
+
'attachment by name against that directory and never through the atlas, so those attachments would ' +
|
|
1319
|
+
'import with no pixels. If this is a `--pack` build, its one packed page is in --out and its parts are ' +
|
|
1320
|
+
'not: rebuild with `--copy-images`, which is the shape whose art travels with the skeleton.',
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// The export is a skeleton only; it needs the build's atlas and pages beside
|
|
1327
|
+
// it to be a candidate anything can load โ so the atlas is step 3's, and it is
|
|
1328
|
+
// read here, before step 1, for the reason the sentence above gives.
|
|
1329
|
+
//
|
|
1330
|
+
// ๐จ Which is why the build has to be SELF-CONTAINED, and this refuses when it
|
|
1331
|
+
// is not. An ordinary build's atlas names its pages by a relative path back to
|
|
1332
|
+
// the art directory; copy that atlas to a directory at another depth and every
|
|
1333
|
+
// page name resolves to nothing. Found by running this tool โ it reported four
|
|
1334
|
+
// `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
|
|
1335
|
+
// the editor's, which is the worst kind of red: a real assertion, correctly
|
|
1336
|
+
// fired, pointing at the wrong culprit.
|
|
1337
|
+
const atlas = readBuildAtlas(opts.build);
|
|
1338
|
+
if ('refusal' in atlas) return fail(atlas.refusal);
|
|
1339
|
+
const wandering = atlas.pageNames.filter((n) => n.includes('/'));
|
|
1340
|
+
if (wandering.length > 0) {
|
|
1341
|
+
fail(
|
|
1342
|
+
`the build's atlas names its pages by path, not by filename โ the first is "${wandering[0]}". ` +
|
|
1343
|
+
'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
|
|
1344
|
+
`${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
|
|
1345
|
+
'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ๐ The `--exported` file, read and parsed here for the same reason (issue
|
|
1350
|
+
// #1107): one skeleton JSON object, in `readBuildSkeleton`'s shape, or refused
|
|
1351
|
+
// by name. It used to be read first by step 4's `skinsDeclaredBy`, bare, so a
|
|
1352
|
+
// file that was not JSON was gated and diffed by steps 3โ4 and then left the
|
|
1353
|
+
// tool as a `SyntaxError` stack. Step 3's candidate is written from these
|
|
1354
|
+
// bytes, so what is measured is what was parsed.
|
|
1355
|
+
let exportedBytes: Buffer | null = null;
|
|
1356
|
+
if (opts.exported !== null) {
|
|
1357
|
+
if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
|
|
1358
|
+
const got = readSkeletonObject(
|
|
1359
|
+
opts.exported,
|
|
1360
|
+
'the --exported file',
|
|
1361
|
+
'Steps 3-6 measure that file as the editor\'s JSON export of this build, so it has to be one skeleton JSON ' +
|
|
1362
|
+
'object โ `skeleton`, `bones`, `slots`, โฆ โ which is what the editor\'s `-e json` export writes.',
|
|
1363
|
+
);
|
|
1364
|
+
if ('refusal' in got) return fail(got.refusal);
|
|
1365
|
+
exportedBytes = got.bytes;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
rmSync(opts.out, { recursive: true, force: true });
|
|
1369
|
+
mkdirSync(join(opts.out, 'export'), { recursive: true });
|
|
1370
|
+
mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
|
|
1371
|
+
|
|
1372
|
+
emit(`## 0 versions`);
|
|
1373
|
+
emit(` rigc ${rigc.how}`);
|
|
1374
|
+
emit(` ${run(rigc.cmd, [...rigc.prefix, '--version'], 60).stdout.trim()}`);
|
|
1375
|
+
|
|
1376
|
+
let exportedJson: string;
|
|
1377
|
+
/** The `x.y.z` step 0 read off the editor, held against the export at step 2 (issue #1199). */
|
|
1378
|
+
let editorRead: string | null = null;
|
|
1379
|
+
/** True when the export's `skeleton.spine` and step 0's version both exist and differ. */
|
|
1380
|
+
let versionDisagrees = false;
|
|
1381
|
+
if (opts.exported !== null) {
|
|
1382
|
+
exportedJson = opts.exported;
|
|
1383
|
+
emit(` editor NOT RUN โ measuring an export the editor already made: ${opts.exported}`);
|
|
1384
|
+
emit('');
|
|
1385
|
+
emit('## 1-2 import / export SKIPPED (--exported)');
|
|
1386
|
+
} else {
|
|
1387
|
+
// ๐ The refusal, and it is the whole licence posture in one branch: with no
|
|
1388
|
+
// editor there is no round trip, and the tool says so rather than measuring
|
|
1389
|
+
// something else and calling it one.
|
|
1390
|
+
if (opts.editor === '' || !existsSync(opts.editor)) {
|
|
1391
|
+
fail(
|
|
1392
|
+
`Spine editor not found at ${opts.editor || '(no default known for platform ' + process.platform + ')'}. ` +
|
|
1393
|
+
'This tool round-trips through a LICENSED Spine editor and has nothing to measure without one. ' +
|
|
1394
|
+
NO_EDITOR_HINT,
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
// ๐ The trial, refused BEFORE it is run (issue #410). The refusal above used
|
|
1398
|
+
// to invite `--editor <path>` on a machine whose only Spine is the trial,
|
|
1399
|
+
// which cannot export and does not fail cleanly โ so a path that names itself
|
|
1400
|
+
// a trial is answered here, off the file system, without a process starting.
|
|
1401
|
+
const named = trialSignalsFromPath(opts.editor);
|
|
1402
|
+
if (named.length > 0) fail(trialRefusal(opts.editor, named));
|
|
1403
|
+
|
|
1404
|
+
const pin = opts.editorVersion === null ? [] : ['-u', opts.editorVersion];
|
|
1405
|
+
// ๐จ Under the same `-u` the import and the export carry (issue #1199). The
|
|
1406
|
+
// call used to be a bare `--version`, which names the editor the LAUNCHER
|
|
1407
|
+
// starts by default: measured 2026-10-07 on a launcher whose default is
|
|
1408
|
+
// 4.3.26, a trip pinned with `-u 4.3.23` exported `"spine": "4.3.23"` and
|
|
1409
|
+
// step 0 printed `Spine 4.3.26 Professional` โ the one line that says which
|
|
1410
|
+
// editor the trip ran on, naming one it did not run on.
|
|
1411
|
+
const ver = run(opts.editor, [...pin, '--version'], 60);
|
|
1412
|
+
emit(` editor ${opts.editor}`);
|
|
1413
|
+
// One line, never the output's tail: the tail is the licensee's (issue #1077).
|
|
1414
|
+
// A pinned run says where the line came from; an unpinned run prints the line
|
|
1415
|
+
// alone, as it always has, and step 2 states its source beside the export's.
|
|
1416
|
+
const stated = editorVersion(`${ver.stdout}\n${ver.stderr}`);
|
|
1417
|
+
editorRead = stated.version;
|
|
1418
|
+
emit(` ${stated.line}${opts.editorVersion === null ? '' : ` (${versionSource(opts.editorVersion)})`}`);
|
|
1419
|
+
// The second signal, and the only one that works on a platform whose trial
|
|
1420
|
+
// path this repository has never seen: the binary's own banner. It costs no
|
|
1421
|
+
// extra call โ `--version` above is one the tool already made.
|
|
1422
|
+
const introduced = trialSignalFromVersion(`${ver.stdout}\n${ver.stderr}`);
|
|
1423
|
+
if (introduced !== null) fail(trialRefusal(opts.editor, [introduced]));
|
|
1424
|
+
|
|
1425
|
+
emit('');
|
|
1426
|
+
emit('## 1 import (json -> project)');
|
|
1427
|
+
const project = join(opts.out, `${opts.name}.spine`);
|
|
1428
|
+
const imported = run(opts.editor, [...pin, '-i', source, '-o', project, '-r', opts.name], opts.timeoutS);
|
|
1429
|
+
emit(` exit=${imported.status}${imported.timedOut ? ` TIMED OUT after ${opts.timeoutS}s` : ''}`);
|
|
1430
|
+
// A step "went wrong" if it reported failure OR did not leave the artifact
|
|
1431
|
+
// it exists to leave. Both are cases where the editor's own words are the
|
|
1432
|
+
// next thing anybody needs, and both used to print only `exit=`.
|
|
1433
|
+
const importWrong = imported.status !== 0 || imported.timedOut || !existsSync(project);
|
|
1434
|
+
if (importWrong) for (const line of editorSaid(imported)) emit(line);
|
|
1435
|
+
if (imported.timedOut) fail(`the editor did not return within ${opts.timeoutS}s on import โ that is a hang, not a result`);
|
|
1436
|
+
if (!existsSync(project)) {
|
|
1437
|
+
fail(`the editor wrote no project file; the import did not happen โ what it printed is above and in ${logPath}`);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
emit('');
|
|
1441
|
+
emit('## 2 export (project -> json, default settings)');
|
|
1442
|
+
const exported = run(opts.editor, [...pin, '-i', project, '-o', join(opts.out, 'export'), '-e', 'json'], opts.timeoutS);
|
|
1443
|
+
emit(` exit=${exported.status}${exported.timedOut ? ` TIMED OUT after ${opts.timeoutS}s` : ''}`);
|
|
1444
|
+
const written = existsSync(join(opts.out, 'export'))
|
|
1445
|
+
? readdirSync(join(opts.out, 'export')).filter((f) => f.endsWith('.json'))
|
|
1446
|
+
: [];
|
|
1447
|
+
if (exported.status !== 0 || exported.timedOut || written.length === 0) {
|
|
1448
|
+
for (const line of editorSaid(exported)) emit(line);
|
|
1449
|
+
}
|
|
1450
|
+
if (exported.timedOut) fail(`the editor did not return within ${opts.timeoutS}s on export โ that is a hang, not a result`);
|
|
1451
|
+
if (written.length === 0) {
|
|
1452
|
+
fail(
|
|
1453
|
+
'the editor wrote no json; stopping before the re-gate rather than measuring nothing โ what it printed ' +
|
|
1454
|
+
`is above and in ${logPath}`,
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
exportedJson = join(opts.out, 'export', written[0]);
|
|
1458
|
+
// ๐ The version line checked against what the trip measured, once there is
|
|
1459
|
+
// an export to read (issue #1199): the editor's own `skeleton.spine`.
|
|
1460
|
+
const agreement = versionAgreement(editorRead, opts.editorVersion, declaredSpineVersion(exportedJson));
|
|
1461
|
+
emit(agreement.line);
|
|
1462
|
+
versionDisagrees = agreement.fail;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// The candidate: the export beside the build's atlas and pages, which were
|
|
1466
|
+
// read before step 1 (see `readBuildAtlas`). An `--exported` file is written
|
|
1467
|
+
// from the bytes parsed there; the editor's own export is copied from disk.
|
|
1468
|
+
const cand = join(opts.out, 'export-cand');
|
|
1469
|
+
if (exportedBytes !== null) writeFileSync(join(cand, 'skeleton.json'), exportedBytes);
|
|
1470
|
+
else copyFileSync(exportedJson, join(cand, 'skeleton.json'));
|
|
1471
|
+
for (const f of readdirSync(opts.build)) {
|
|
1472
|
+
if (f.endsWith('.atlas') || f.endsWith('.png')) copyFileSync(join(opts.build, f), join(cand, f));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
emit('');
|
|
1476
|
+
emit('## 3 validate --profile spine (the export)');
|
|
1477
|
+
const gate = run(rigc.cmd, [...rigc.prefix, 'validate', cand, '--profile', 'spine'], 600);
|
|
1478
|
+
emit(` exit=${gate.status}`);
|
|
1479
|
+
for (const line of verdictLines(gate.stdout)) emit(` ${line}`);
|
|
1480
|
+
|
|
1481
|
+
emit('');
|
|
1482
|
+
emit('## 4 diff (export against the build)');
|
|
1483
|
+
const diffJson = join(opts.out, 'diff.json');
|
|
1484
|
+
const diff = run(rigc.cmd, [...rigc.prefix, 'diff', join(cand, 'skeleton.json'), source, '--json', diffJson], 600);
|
|
1485
|
+
emit(` exit=${diff.status}`);
|
|
1486
|
+
for (const line of verdictLines(diff.stdout)) emit(` ${line}`);
|
|
1487
|
+
// Read the numbers out of the REPORT rather than off stdout: the measures
|
|
1488
|
+
// that moved are the answer to "what did the editor change", and scraping a
|
|
1489
|
+
// console layout for them would break the first time that layout is tidied.
|
|
1490
|
+
for (const line of diffSummaryLines(diffJson)) emit(` ${line}`);
|
|
1491
|
+
|
|
1492
|
+
// ๐ The skins come off BOTH files, compared before anything is rendered
|
|
1493
|
+
// (issue #801). A skin the export dropped is named here as LOST BY THE EXPORT
|
|
1494
|
+
// and fails the run; enumerating the export's skins alone would quietly stop
|
|
1495
|
+
// looking for it, and enumerating the build's alone rendered a skin that was
|
|
1496
|
+
// not there and reported two unrelated refusals for it.
|
|
1497
|
+
const buildSkins = skinsDeclaredBy(source);
|
|
1498
|
+
const roster = skinRoster(buildSkins, skinsDeclaredBy(join(cand, 'skeleton.json')));
|
|
1499
|
+
const skinsFigure = diffFigure(diffJson, 'attachments.skins') ?? '(no `attachments.skins` measure in the diff report)';
|
|
1500
|
+
for (const [side, lost] of [
|
|
1501
|
+
['lost by the export: the build declares it and the export does not', roster.lostByExport],
|
|
1502
|
+
['added by the export: the export declares it and the build does not', roster.addedByExport],
|
|
1503
|
+
] as const) {
|
|
1504
|
+
for (const skin of lost) {
|
|
1505
|
+
emit(
|
|
1506
|
+
` FAIL skin ${JSON.stringify(skin)} is ${side} (diff attachments.skins ${skinsFigure}) โ it is rendered ` +
|
|
1507
|
+
'on neither side, because a render of a skin one file does not have measures nothing',
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const rosterClean = roster.lostByExport.length === 0 && roster.addedByExport.length === 0;
|
|
1512
|
+
// The no-skin block is for a skeleton pair that declares no skin on EITHER
|
|
1513
|
+
// side; a build whose every skin the export dropped has nothing left to
|
|
1514
|
+
// render, and that is said above rather than drawn here with no skin set.
|
|
1515
|
+
const blocks = buildSkins.length > 0 && roster.both.length === 0 ? [] : skinBlocks(roster.both, opts.out, opts.fps);
|
|
1516
|
+
const checks: Array<{ skin: string | null; verdict: BlockVerdict; status: number | null; mae: number | null }> = [];
|
|
1517
|
+
for (const block of blocks) {
|
|
1518
|
+
emit('');
|
|
1519
|
+
emit(block.heading);
|
|
1520
|
+
const args = ['--fps', String(opts.fps), ...block.args];
|
|
1521
|
+
const drawBuild = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, ...args, '--out', block.buildFrames], 900);
|
|
1522
|
+
const drawExport = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, ...args, '--out', block.exportFrames], 900);
|
|
1523
|
+
// ๐จ Both renderers are quoted the moment either did not do what it was for
|
|
1524
|
+
// (issue #621) โ the rule steps 1 and 2 have followed since #541, and step 5
|
|
1525
|
+
// was the one step that did not.
|
|
1526
|
+
for (const [what, ran] of [
|
|
1527
|
+
['render (the build)', drawBuild],
|
|
1528
|
+
['render (the export)', drawExport],
|
|
1529
|
+
] as const) {
|
|
1530
|
+
if (ran.status !== 0 || ran.timedOut) for (const line of rigcSaid(what, ran)) emit(line);
|
|
1531
|
+
}
|
|
1532
|
+
const buildBlank = nothingToDraw(drawBuild);
|
|
1533
|
+
const exportBlank = nothingToDraw(drawExport);
|
|
1534
|
+
const buildDrew = drawBuild.status === 0 && !drawBuild.timedOut;
|
|
1535
|
+
const exportDrew = drawExport.status === 0 && !drawExport.timedOut;
|
|
1536
|
+
|
|
1537
|
+
if (buildBlank && exportBlank) {
|
|
1538
|
+
emit(` SKIP ${NOTHING_MEASURED}`);
|
|
1539
|
+
checks.push({ skin: block.skin, verdict: 'not measured', status: null, mae: null });
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
// ๐ One side only, and it stays red. "Nothing to draw" is an honest answer
|
|
1543
|
+
// about a RIG; about one side of a round trip it is the loss the trip exists
|
|
1544
|
+
// to find, so the SKIP above is guarded by `&&` and never by `||`.
|
|
1545
|
+
if ((buildBlank && exportDrew) || (exportBlank && buildDrew)) {
|
|
1546
|
+
const blank = buildBlank ? 'the build' : 'the export';
|
|
1547
|
+
const drawn = buildBlank ? 'the export' : 'the build';
|
|
1548
|
+
emit(
|
|
1549
|
+
` FAIL ${blank} has nothing to draw and ${drawn} draws โ one side drawing where the other does not IS ` +
|
|
1550
|
+
'the divergence this step measures, so it is a failure and never a SKIP',
|
|
1551
|
+
);
|
|
1552
|
+
checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (!buildDrew || !exportDrew) {
|
|
1556
|
+
emit(
|
|
1557
|
+
' FAIL a render did not do what it was for, so there is no frame set to compare โ `check` is not run, ' +
|
|
1558
|
+
'because with a frame set missing its message would name the directory rather than the refusal above',
|
|
1559
|
+
);
|
|
1560
|
+
checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const check = run(
|
|
1564
|
+
rigc.cmd,
|
|
1565
|
+
[...rigc.prefix, 'check', '--candidate', cand, '--frames', block.buildFrames, ...block.args, '--json', block.checkJson],
|
|
1566
|
+
900,
|
|
1567
|
+
);
|
|
1568
|
+
emit(` exit=${check.status}`);
|
|
1569
|
+
if (check.status !== 0 || check.timedOut) for (const line of rigcSaid('check', check)) emit(line);
|
|
1570
|
+
for (const line of verdictLines(check.stdout)) emit(` ${line}`);
|
|
1571
|
+
for (const line of checkFigures(block.checkJson)) emit(` ${line}`);
|
|
1572
|
+
checks.push({ skin: block.skin, verdict: 'measured', status: check.status, mae: worstMeanMae(block.checkJson) });
|
|
1573
|
+
}
|
|
1574
|
+
// The roll-up, so a loss in one skin of many is a line somebody reads rather
|
|
1575
|
+
// than a row buried in the block above it. The mark is on the LARGEST figure
|
|
1576
|
+
// and only where the skins disagree: `check` has no pass mark to compare
|
|
1577
|
+
// against and this tool does not get to invent one, but "these skins did not
|
|
1578
|
+
// come back the same" is a fact the run itself produced.
|
|
1579
|
+
if (blocks.length > 1) {
|
|
1580
|
+
// โ ๏ธ Off the MEASURED blocks only (issue #621). A skin nobody could render
|
|
1581
|
+
// has no figure, and folding it in as one would put a number in the column a
|
|
1582
|
+
// reader looks at for a block where nothing was compared.
|
|
1583
|
+
const figures = checks.filter((c) => c.verdict === 'measured').map((c) => c.mae).filter((v): v is number => v !== null);
|
|
1584
|
+
const worst = figures.length === 0 ? null : Math.max(...figures);
|
|
1585
|
+
const agreed = figures.length === checks.length && new Set(figures).size === 1;
|
|
1586
|
+
emit('');
|
|
1587
|
+
emit(` per skin ${checks.length} block(s)`);
|
|
1588
|
+
for (const { skin, verdict, status, mae } of checks) {
|
|
1589
|
+
// ๐ Never a pass and never an MAE of 0: a block that measured nothing
|
|
1590
|
+
// says so in the column the figures would have been in.
|
|
1591
|
+
if (verdict !== 'measured') {
|
|
1592
|
+
emit(
|
|
1593
|
+
` ${String(skin).padEnd(20)} ` +
|
|
1594
|
+
(verdict === 'not measured'
|
|
1595
|
+
? 'NOT MEASURED โ neither side draws a frame under this skin'
|
|
1596
|
+
: 'โ ๏ธ NOT MEASURED โ a render did not do what it was for, and this skin is red for it'),
|
|
1597
|
+
);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
emit(
|
|
1601
|
+
` ${String(skin).padEnd(20)} check exit=${status} worst mean MAE ` +
|
|
1602
|
+
`${mae === null ? '(no report)' : mae.toFixed(4)}` +
|
|
1603
|
+
`${status === 0 ? '' : ' โ ๏ธ this skin did not come back'}` +
|
|
1604
|
+
// The mark compares figures, so it needs two of them: crowning the one
|
|
1605
|
+
// skin that WAS measured "the worst" says nothing and reads as a loss.
|
|
1606
|
+
`${!agreed && figures.length > 1 && mae !== null && mae === worst ? ` โ ๏ธ the worst of the ${figures.length} measured skins` : ''}`,
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
if (agreed) emit(` โคท every skin came back at the same figure, so no skin is carrying a difference the others are not.`);
|
|
1610
|
+
}
|
|
1611
|
+
// ๐ `not measured` is the one verdict that does not fail the run: nothing was
|
|
1612
|
+
// compared, and a round trip that ends red on a correct rig is the shape #608
|
|
1613
|
+
// removed from `build` (issue #621).
|
|
1614
|
+
const checksClean = checks.every((c) => c.verdict === 'not measured' || (c.verdict === 'measured' && c.status === 0));
|
|
1615
|
+
|
|
1616
|
+
emit('');
|
|
1617
|
+
emit('## 6 what the editor rewrote');
|
|
1618
|
+
const rows = shapeDiff(shapeOf(source), shapeOf(join(cand, 'skeleton.json')));
|
|
1619
|
+
if (rows.length === 0) emit(' nothing at this resolution: same version, counts, animations and timeline kinds');
|
|
1620
|
+
for (const row of rows) emit(` ${row}`);
|
|
1621
|
+
|
|
1622
|
+
keepLog();
|
|
1623
|
+
emit('');
|
|
1624
|
+
emit(`log: ${logPath}`);
|
|
1625
|
+
// The verdict is the gate's and EVERY skin's check, not this tool's opinion of
|
|
1626
|
+
// them: one skin coming back wrong is the whole run coming back wrong, which
|
|
1627
|
+
// is the half a single un-skinned check could not say.
|
|
1628
|
+
// A version line that disagrees with the export is red too (issue #1199): the
|
|
1629
|
+
// report would otherwise name an editor the trip did not run on, green.
|
|
1630
|
+
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees ? 0 : 1);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// โญ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
|
|
1634
|
+
// READ by a control that has no editor (issues #561, #571). Every other `ERT`
|
|
1635
|
+
// case drives this file as a subprocess, which is the right shape for a refusal;
|
|
1636
|
+
// step 6's summary is a pure function of two skeleton files and step 5's plan is
|
|
1637
|
+
// a pure function of one, and driving an editor โ or four rigc subcommands โ to
|
|
1638
|
+
// reach either would be paying for a round trip to test arithmetic. Run as a
|
|
1639
|
+
// program this is unchanged: `bun tools/editor_roundtrip.ts โฆ` makes this module
|
|
1640
|
+
// the entry, so `import.meta.main` is true.
|
|
1641
|
+
if (import.meta.main) main();
|