rig-c 0.0.0-stage โ†’ 2.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
@@ -0,0 +1,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();