spine-rigc 0.22.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validate.ts CHANGED
@@ -114,13 +114,19 @@ export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
114
114
  * archetype — a structural rule about rigc's own formations, meaningless to a
115
115
  * skeleton rigc did not compile.
116
116
  *
117
- * Three assertions are MIXED and are marked `validity` here because their
117
+ * Two assertions are MIXED and are marked `validity` here because their
118
118
  * validity half must never stop running; their policy clauses are gated inside
119
119
  * the assertion body against `profile`, and each such clause says so where it
120
120
  * lives. They are A06 (size-vs-PNG is validity; pma / rotation / full-page
121
- * coverage are policy), A08 (the attachment→region join is validity; requiring
122
- * the two names to be identical is policy) and A20 (weight coherence is
123
- * validity; requiring a mesh to be weighted at all is policy).
121
+ * coverage are policy) and A20 (weight coherence is validity; requiring a mesh
122
+ * to be weighted at all is policy).
123
+ *
124
+ * A08 was the third until issue #574 retired its policy clause. It required a
125
+ * skin entry's placeholder to be spelled like the region it resolves to, under
126
+ * a renderer that resolves art by `path` and has never read a placeholder — so
127
+ * restating it as the renderer's own join made it a tautology over the validity
128
+ * half. The argument, with the renderer lines it is measured against, sits above
129
+ * `check('A08_…')`.
124
130
  */
125
131
  const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
126
132
  A00_ROUNDTRIP_PARSE: 'validity',
@@ -131,7 +137,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
131
137
  A05_CURVE_ARRAY_LENGTH: 'validity',
132
138
  A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
133
139
  A07_ATLAS_TEXT_SHAPE: 'validity',
134
- A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // mixed — see above
140
+ A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
135
141
  A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
136
142
  A10_NO_NAN_AFTER_STEPPING: 'validity',
137
143
  A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
@@ -183,6 +189,22 @@ export function assertionCountForProfile(profile: ValidateProfile): number {
183
189
  return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
184
190
  }
185
191
 
192
+ /**
193
+ * What a SKIP says when `A00_ROUNDTRIP_PARSE` handed an assertion nothing to
194
+ * look at.
195
+ *
196
+ * Two of them rather than one, because what an assertion was denied is the whole
197
+ * content of its SKIP: `A20` wanted the loaded skeleton and `A06` wanted the
198
+ * loaded atlas, and a reader who is told which can tell a rule that is waiting
199
+ * on the parse from one that is waiting on the art. Both point at A00 instead of
200
+ * restating its detail, which the FAIL row prints in full.
201
+ *
202
+ * Exported so a control compares against them rather than quoting them — the
203
+ * same reason `ASSERTION_NAMES` is exported.
204
+ */
205
+ export const SKIP_NO_SKELETON = 'the round trip did not produce a skeleton to measure (A00 owns that failure)';
206
+ export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure (A00 owns that failure)';
207
+
186
208
  export interface ValidateInput {
187
209
  skeletonText: string;
188
210
  atlasText: string;
@@ -1282,8 +1304,20 @@ export function validate(input: ValidateInput): ValidateReport {
1282
1304
  check('A14_NO_FULL_FRAME_MESH', () => {
1283
1305
  const stageW = data.width || 0;
1284
1306
  const stageH = data.height || 0;
1307
+ // ⚠️ A stage-less skeleton has nothing for a mesh to span, and this rule
1308
+ // used to report that as a PASS — the `stageW && stageH` guard below reads
1309
+ // as a measurement of a 0x0 stage that no mesh can reach. It was only ever
1310
+ // reachable from a foreign file until a rig spec could *declare* no stage
1311
+ // (issue #578), and a pass certifying an unmeasured rig is the exact
1312
+ // failure mode `A21_MESH_RIM_PINNED`'s `|| 'ring'` default was (#44).
1313
+ if (!stageW || !stageH) {
1314
+ return skip(
1315
+ 'A14_NO_FULL_FRAME_MESH',
1316
+ 'the skeleton declares no stage size, so there is no full frame for a mesh to span',
1317
+ );
1318
+ }
1285
1319
  for (const mesh of meshAttachments) {
1286
- if (stageW && stageH && mesh.width >= stageW && mesh.height >= stageH) {
1320
+ if (mesh.width >= stageW && mesh.height >= stageH) {
1287
1321
  fail('A14_NO_FULL_FRAME_MESH', `mesh "${mesh.name}" spans the whole ${stageW}x${stageH} stage`);
1288
1322
  }
1289
1323
  }
@@ -1307,8 +1341,19 @@ export function validate(input: ValidateInput): ValidateReport {
1307
1341
  }
1308
1342
  }
1309
1343
  }
1344
+ // The same shape as A06's and A17's guards, found by auditing for it
1345
+ // (#568): a rig with no `idle` at all has nothing here to be wrong, and a
1346
+ // rule that reports "held" over a subject that does not exist is the
1347
+ // vacuous pass this file's own doctrine refuses. The two states get their
1348
+ // own sentences because they are different absences — no such animation,
1349
+ // versus one that keys no bone.
1310
1350
  const idle = isObj(raw?.animations) ? (raw.animations as Json).idle : undefined;
1311
- if (!isObj(idle) || !isObj(idle.bones)) return;
1351
+ if (!isObj(idle)) {
1352
+ return skip('A15_IDLE_NO_MESH_BONE_KEYS', 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
1353
+ }
1354
+ if (!isObj(idle.bones)) {
1355
+ return skip('A15_IDLE_NO_MESH_BONE_KEYS', '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
1356
+ }
1312
1357
  for (const boneName of Object.keys(idle.bones as Json)) {
1313
1358
  if (meshBoneNames.has(boneName)) {
1314
1359
  fail('A15_IDLE_NO_MESH_BONE_KEYS', `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip`);
@@ -2486,7 +2531,46 @@ export function validate(input: ValidateInput): ValidateReport {
2486
2531
  stats.skinMembers = listed;
2487
2532
  });
2488
2533
 
2489
- // --- A08: region names exact-match attachment names --------------------
2534
+ // --- A08: every attachment's path resolves to a region the atlas has ---
2535
+ //
2536
+ // 🗑️ A08 used to be MIXED: the join above is validity, and a second clause
2537
+ // gated on `spine-html` required the skin entry's PLACEHOLDER to be spelled
2538
+ // exactly like the region it resolves to ("v0 requires them identical").
2539
+ // That clause is retired (issue #574), and the reason is that the renderer
2540
+ // it was profiled under never performed the join it described.
2541
+ //
2542
+ // `spine-html@0.4.1` resolves art in two steps and the placeholder is in
2543
+ // neither. `DomTexture.js:78,102` builds the image map with
2544
+ // `put(atlasRegion.name, …)` over every region of the atlas, and
2545
+ // `SpineHtmlRenderer.js:172` reads it back as
2546
+ // `const regionImage = region && this.regionImages.get(region.name)`, where
2547
+ // `region` came off the attachment — which `AtlasAttachmentLoader` resolved
2548
+ // through `path`. Every published version of that renderer keys the same way
2549
+ // (checked 0.1.0 through 0.4.1, the whole series). Nothing in it reads an
2550
+ // attachment's name, let alone its placeholder.
2551
+ //
2552
+ // ⇒ Restated as the join the renderer actually performs, the clause compares
2553
+ // `path` against the region `path` resolved — a tautology, and the same
2554
+ // string the validity check above already requires the atlas to hold. An
2555
+ // assertion that cannot fail is not a gate, so it is removed rather than
2556
+ // kept as a duplicate.
2557
+ //
2558
+ // ⚠️ It was not merely inert, either: it refused rigs on both sides of the
2559
+ // convention it was written for. `path` exists precisely so a placeholder
2560
+ // may differ from the PNG basename (R5), and since issue #567 a placeholder
2561
+ // two named skins share is emitted as `<skin>/<placeholder>` with `path`
2562
+ // restated to the basename — the only spelling the Spine editor holds. Under
2563
+ // the old clause that shape, and any rig that merely named a part something
2564
+ // other than its placeholder, was red under `spine-html` while the editor
2565
+ // imported it and the renderer drew it.
2566
+ //
2567
+ // 📏 What is left, measured rather than assumed: only the LAST loop can
2568
+ // fire. Both attachment-join clauses are preempted by the loader —
2569
+ // `AtlasAttachmentLoader.findRegion` throws
2570
+ // `Region not found in atlas: <path> (attachment: <name>)` for a path that
2571
+ // names no region and for one padded with whitespace alike, so A00 reports
2572
+ // it and A08 never runs. `PS90` is the mutant for the loop that does fire,
2573
+ // and it is the first mutant this assertion has ever had.
2490
2574
  check('A08_REGION_NAMES_MATCH_ATTACHMENTS', () => {
2491
2575
  const regionNames = new Set(atlas!.regions.map((r) => r.name));
2492
2576
  for (const skin of data.skins) {
@@ -2500,16 +2584,6 @@ export function validate(input: ValidateInput): ValidateReport {
2500
2584
  if (!regionNames.has(lookup)) {
2501
2585
  fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" wants region "${lookup}", which the atlas does not have`);
2502
2586
  }
2503
- // 📐 PROFILE. That the join RESOLVES is validity — an attachment
2504
- // pointing at a region the atlas does not have is a hole in the rig
2505
- // whoever loads it. That the two names are IDENTICAL is rigc's v0
2506
- // policy: it holds because a region name IS the PNG basename here, and
2507
- // rigc's own packer keeps it that way (issue #4), while a foreign
2508
- // packer renames regions by design (spineboy's `path` differs from its
2509
- // placeholder in 26 attachments).
2510
- if (policy && entry.placeholder !== lookup) {
2511
- fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" resolves to region "${lookup}"; v0 requires them identical`);
2512
- }
2513
2587
  }
2514
2588
  }
2515
2589
  for (const region of atlas!.regions) {
@@ -2620,15 +2694,26 @@ export function validate(input: ValidateInput): ValidateReport {
2620
2694
  // --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
2621
2695
  // Case 6h: a `size:` that disagrees with the file loads fine and collapses
2622
2696
  // every UV — rigid stays correct, meshes sample a corner scrap.
2697
+ //
2698
+ // 🚨 The guard is a SKIP and never a bare `return` (issue #568). A `return`
2699
+ // inside `check()` leaves the failure count and the skip count where they
2700
+ // were, which is precisely how that function decides an assertion PASSED — so
2701
+ // for as long as this line read `if (!atlas) return;` a candidate whose atlas
2702
+ // the round trip could not load was reported green by the two rules whose only
2703
+ // subject IS the atlas. It was measured on a fixture carrying a page that
2704
+ // declares twice the size of its PNG, the exact defect A06 names when the
2705
+ // parse succeeds: with a region also removed so the round trip threw, A06
2706
+ // printed `PASS`. Everything below reads `atlas.pages`, so a report of "held"
2707
+ // here is a report about zero pages.
2623
2708
  check('A17_ATLAS_PAGE_FILES_EXIST', () => {
2624
- if (!atlas) return;
2709
+ if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
2625
2710
  for (const page of atlas.pages) {
2626
2711
  const abs = resolve(input.atlasDir, page.name);
2627
2712
  if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
2628
2713
  }
2629
2714
  });
2630
2715
  check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
2631
- if (!atlas) return;
2716
+ if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
2632
2717
  for (const page of atlas.pages) {
2633
2718
  const abs = resolve(input.atlasDir, page.name);
2634
2719
  if (!existsSync(abs)) continue; // A17 owns this
@@ -2667,8 +2752,12 @@ export function validate(input: ValidateInput): ValidateReport {
2667
2752
  // overlapping rectangles put one drawing inside another's.
2668
2753
  //
2669
2754
  // ⚠️ Rotation stays refused either way, and that is not the same clause: it
2670
- // is about rigc's own packer never turning a region, and `extractRegion`
2671
- // refusing to read one back.
2755
+ // is about rigc's own packer never turning a region, which is a statement
2756
+ // about what rigc WRITES. It stopped being a statement about what rigc can
2757
+ // read in issue #570 — `extractRegion` now lifts a rotated region back off
2758
+ // its page as a transcription of `MeshAttachment.computeUVs` — and the two
2759
+ // must not be re-merged: an artifact under the renderer's own rulebook that
2760
+ // rigc did not pack is still a foreign artifact, whatever rigc can measure.
2672
2761
  const regionsPerPage = new Map<string, TextureAtlasRegion[]>();
2673
2762
  for (const region of atlas.regions) {
2674
2763
  const on = regionsPerPage.get(region.page.name);
@@ -2745,7 +2834,11 @@ export function validate(input: ValidateInput): ValidateReport {
2745
2834
  // that does not ask — the reader opted in, and the message is where they find
2746
2835
  // out what they opted into.
2747
2836
  check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => {
2748
- if (!atlas) return;
2837
+ // A SKIP for the same reason A06's is (#568). This one is invisible under
2838
+ // `spine`, where the profile excludes the rule before its body runs — and
2839
+ // that is exactly why it was worth finding: `--profile spine-html` reported
2840
+ // it, and A27 below, green on an atlas nothing had read.
2841
+ if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
2749
2842
  const stageW = skeletonData?.width ?? 0;
2750
2843
  const stageH = skeletonData?.height ?? 0;
2751
2844
  const basePages = new Set<string>();
@@ -2941,14 +3034,24 @@ export function validate(input: ValidateInput): ValidateReport {
2941
3034
  }
2942
3035
  });
2943
3036
 
2944
- // --- A26: draw order matches the rig's slot table ------------------------
3037
+ // --- A26: the slots array IS the rig's slot table ------------------------
2945
3038
  //
2946
3039
  // The slots array IS the draw order (z-index = array index), so a formation
2947
3040
  // whose illusion depends on one part occluding another depends on one
2948
3041
  // adjacency in that array — and nothing in the file objects to the wrong
2949
3042
  // order. On a still frame it can even look plausible. The rig's own slot list
2950
- // is the canonical table; a cut that fills only some of those slots emits a
2951
- // SUBSEQUENCE of it, which is what this checks.
3043
+ // is the canonical table, and this checks the emitted array against it in
3044
+ // both directions: nothing out of order, and nothing missing.
3045
+ //
3046
+ // ⚠️ The second half is new with issue #575 and the first half is why it had
3047
+ // to be. This clause used to accept any SUBSEQUENCE of the table, because the
3048
+ // compiler dropped a slot no skin filled and the gate was written around that
3049
+ // — so the defect #575 filed was licensed by the assertion that was supposed
3050
+ // to catch it: two production exports declaring 53 and 61 slots built green
3051
+ // at 51 and 57. `compile` now emits every declared slot, empty if nothing
3052
+ // fills it, so a shorter array is a loss and is named as one here. A slot
3053
+ // missing from the array moves every slot below it up one index, which is
3054
+ // what a `drawOrder` key's offsets are counted against.
2952
3055
  check('A26_SLOT_DRAW_ORDER', () => {
2953
3056
  if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
2954
3057
  const order = input.rig.slotOrder;
@@ -2976,6 +3079,17 @@ export function validate(input: ValidateInput): ValidateReport {
2976
3079
  }
2977
3080
  at = found + 1;
2978
3081
  }
3082
+ const emitted = new Set(names);
3083
+ const missing = order.filter((name) => !emitted.has(name));
3084
+ if (missing.length > 0) {
3085
+ fail(
3086
+ 'A26_SLOT_DRAW_ORDER',
3087
+ `the rig "${input.rig.archetype}" declares ${order.length} slot(s) and the skeleton has ${names.length}: ` +
3088
+ `${missing.map((name) => `"${name}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} declared and ` +
3089
+ 'not emitted. A slot nothing fills is emitted with no setup attachment, not dropped — dropping one ' +
3090
+ 'moves every slot below it up one index',
3091
+ );
3092
+ }
2979
3093
  });
2980
3094
 
2981
3095
  // --- A27: region name == the PNG's basename ------------------------------
@@ -2987,7 +3101,7 @@ export function validate(input: ValidateInput): ValidateReport {
2987
3101
  // would load with the wrong pixels under the right name. One part per page (A06
2988
3102
  // forces it) makes the check exact.
2989
3103
  check('A27_REGION_NAME_MATCHES_PAGE_FILENAME', () => {
2990
- if (!atlas) return;
3104
+ if (!atlas) return skip('A27_REGION_NAME_MATCHES_PAGE_FILENAME', SKIP_NO_ATLAS);
2991
3105
  const perPage = new Map<string, number>();
2992
3106
  for (const region of atlas.regions) perPage.set(region.page.name, (perPage.get(region.page.name) ?? 0) + 1);
2993
3107
  for (const region of atlas.regions) {
@@ -3173,6 +3287,52 @@ export function validate(input: ValidateInput): ValidateReport {
3173
3287
  }
3174
3288
  });
3175
3289
 
3290
+ // --- every assertion leaves a row ----------------------------------------
3291
+ //
3292
+ // 🔒 **A missing row is the vacuous pass one level up** (issue #568). Twenty
3293
+ // assertions live inside `if (skeletonData) {` above, so a round trip that
3294
+ // throws does not skip them — it never reaches them, and they appear in none
3295
+ // of the four lists. Measured on a fixture whose atlas was missing a region
3296
+ // the skeleton names: 13 of 42 printed a verdict, 9 more printed `PROF`, and
3297
+ // 20 printed nothing at all. The run exits 1 and the reader is right to fix
3298
+ // A00 first, which is exactly why the silence survives — nobody counts the
3299
+ // rows on a red run. A report that names 22 of 42 and says nothing about the
3300
+ // rest is telling a reader that those rules are fine, in the only way a
3301
+ // report can: by not mentioning them.
3302
+ //
3303
+ // ⚠️ The sweep DERIVES its list rather than keeping one, because a hand-kept
3304
+ // roster of "assertions behind the round trip" is a second place to update
3305
+ // and would be wrong the first time somebody moved a `check` call. Anything
3306
+ // `ASSERTION_NAMES` knows that reached here with no row did not run, and what
3307
+ // it is honest to say about it is decided by the state:
3308
+ //
3309
+ // * the profile does not carry that kind of rule — `PROF`, the same verdict
3310
+ // `check()`'s own guard would have recorded had the call been reached;
3311
+ // * the round trip produced nothing — `SKIP`, naming that;
3312
+ // * anything else — a FAIL, **by name**. An assertion that vanished while
3313
+ // the skeleton was loaded is a defect in this file, and inventing a SKIP
3314
+ // for it would be the same silence one ring further out. The one state
3315
+ // the sweep is allowed to explain is the one it can prove.
3316
+ const reported = new Set<string>([
3317
+ ...passed,
3318
+ ...failures.map((f) => f.assertion),
3319
+ ...skipped.map((s) => s.assertion),
3320
+ ...profileSkipped.map((p) => p.assertion),
3321
+ ]);
3322
+ for (const name of ASSERTION_NAMES) {
3323
+ if (reported.has(name)) continue;
3324
+ const kind = ASSERTION_KIND[name];
3325
+ if (kind !== 'validity' && !policy) profileSkipped.push({ assertion: name, kind });
3326
+ else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
3327
+ else {
3328
+ fail(
3329
+ name,
3330
+ 'the assertion left no row: its body was never reached, and the round trip that would explain that ' +
3331
+ 'succeeded. A guard above returned before the call — find it and make it a SKIP naming what was absent',
3332
+ );
3333
+ }
3334
+ }
3335
+
3176
3336
  return { failures, passed, skipped, profileSkipped, profile, stats };
3177
3337
  }
3178
3338
 
@@ -3273,6 +3433,24 @@ export function reportLines(report: ValidateReport): string[] {
3273
3433
  for (const s of report.skipped) lines.push(` SKIP ${s.assertion}: ${s.reason}`);
3274
3434
  for (const p of report.profileSkipped) lines.push(` PROF ${p.assertion}: ${p.kind} rule, not in profile "${report.profile}"`);
3275
3435
  for (const f of report.failures) lines.push(` FAIL ${f.assertion}: ${f.detail}`);
3436
+ // How many of them MEASURED anything, which is the figure the rows above do
3437
+ // not hand a reader (issue #568). Counting `PASS` lines answers a different
3438
+ // question — before the sweep that closed #568 a run could print seven of
3439
+ // them over a candidate on which five rules had not executed at all.
3440
+ //
3441
+ // ⚠️ Every figure here is a count of ASSERTIONS and not of rows, which is why
3442
+ // the failed side is a Set: `fail()` is called once per finding, so one
3443
+ // assertion can print six `FAIL` lines, and a line-count would report 47 of
3444
+ // 42. The four buckets partition `ASSERTION_NAMES`, so the total is derived
3445
+ // by adding them rather than stated — a `42` written here would be the one
3446
+ // number in the report that no run could contradict.
3447
+ const failed = new Set(report.failures.map((f) => f.assertion));
3448
+ const measured = report.passed.length + failed.size;
3449
+ const total = measured + report.skipped.length + report.profileSkipped.length;
3450
+ lines.push(
3451
+ ` .. ${total} assertions: ${measured} measured (${report.passed.length} passed, ${failed.size} failed), ` +
3452
+ `${report.skipped.length} skipped, ${report.profileSkipped.length} not in profile "${report.profile}"`,
3453
+ );
3276
3454
  return lines;
3277
3455
  }
3278
3456
 
@@ -2,8 +2,9 @@
2
2
  * The editor round trip, as a tool (issue #374).
3
3
  *
4
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. It is the one
6
- * measurement that answers *"does the editor accept what rigc wrote, and does
5
+ * `render` and `check` the export against the build it came from — the last two
6
+ * **once per skin the build declares** (issue #571; see `skinBlocks`). It is the
7
+ * one measurement that answers *"does the editor accept what rigc wrote, and does
7
8
  * what comes back still play the same"*, and on its first run it found three
8
9
  * emitter defects (#368 `hull`/`edges`, #369 hold curves, #370
9
10
  * `skeleton.images`) before proving that a human edit survives the trip.
@@ -383,6 +384,123 @@ function checkFigures(reportPath: string): string[] {
383
384
  );
384
385
  }
385
386
 
387
+ /**
388
+ * The largest per-animation `meanMae` in one check report, or `null` when there
389
+ * is no report to read one from.
390
+ *
391
+ * ⚠️ `check`'s exit code is not a verdict — there is no pass mark in it, by
392
+ * design, any more than there is one in `diff` — so a per-skin roll-up built on
393
+ * exit codes alone reports every skin as fine and reports it in the column a
394
+ * reader looks at. This is the figure that actually moves when one skin's art
395
+ * comes back wrong, which is what makes the roll-up a roll-up (issue #571).
396
+ */
397
+ function worstMeanMae(reportPath: string): number | null {
398
+ if (!existsSync(reportPath)) return null;
399
+ const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { animations?: Array<{ meanMae?: number }> };
400
+ const values = (report.animations ?? []).map((a) => a.meanMae).filter((v): v is number => typeof v === 'number');
401
+ return values.length === 0 ? null : Math.max(...values);
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // step 5's per-skin plan (issue #571)
406
+ // ---------------------------------------------------------------------------
407
+ //
408
+ // 🚨 Step 5 used to render and check ONCE, with no skin, which draws the default
409
+ // skin and nothing else. On a rig whose named skins carry the contested art that
410
+ // is a comparison of blank against blank: the ninth round trip read `check`
411
+ // 0.0000 on a rig where the construct under test lives in a named skin, and the
412
+ // zero was true and empty at once. So the plan below is one block PER DECLARED
413
+ // SKIN, and a skin whose art the editor moved is a red row with its own name on
414
+ // it rather than a figure nobody rendered.
415
+ //
416
+ // ⭐ Split out as pure functions for the reason `shapeOf`/`shapeDiff` are: the
417
+ // `ERT` suite has no editor and never will, and a loop that can only be read by
418
+ // driving one is a loop nobody has seen work.
419
+
420
+ /**
421
+ * The skins a skeleton file declares, in the order the file lists them.
422
+ *
423
+ * ⚠️ Off the FILE rather than off `spine-core`: this is the build's own
424
+ * `skeleton.json`, read before any of it is loaded, and the tool's other summary
425
+ * (`shapeOf`) reads the same file the same way. A skeleton with no `skins` array
426
+ * at all comes back empty, which is a different fact from `["default"]` and is
427
+ * carried as one — see `skinBlocks`.
428
+ */
429
+ export function skinsDeclaredBy(path: string): string[] {
430
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { skins?: Array<{ name?: unknown }> };
431
+ const out: string[] = [];
432
+ for (const skin of parsed.skins ?? []) {
433
+ // A skin with no usable name is counted under one rather than dropped: a
434
+ // block that vanished would be a skin nobody rendered and nobody missed.
435
+ out.push(typeof skin?.name === 'string' && skin.name !== '' ? skin.name : '(unnamed)');
436
+ }
437
+ return out;
438
+ }
439
+
440
+ /** One render-and-check block of step 5: which skin, where its frames go. */
441
+ export interface SkinBlock {
442
+ /** The skin this block poses under — `null` when the skeleton declares none. */
443
+ skin: string | null;
444
+ /** The heading printed above the block, which is where the skin's name is read. */
445
+ heading: string;
446
+ /** `--skin <name>`, or nothing at all when there is no skin to name. */
447
+ args: string[];
448
+ buildFrames: string;
449
+ exportFrames: string;
450
+ checkJson: string;
451
+ }
452
+
453
+ /**
454
+ * A directory name for one skin's frames: its index, then what of its name is a
455
+ * filename.
456
+ *
457
+ * The index leads because **a skin name is not a path**. The Spine editor writes
458
+ * folders into skin names with `/`, so `goblins/green` would land two levels
459
+ * down or collide with a sibling, and any scheme that replaces the offending
460
+ * characters can map two distinct skins onto one directory. The index cannot
461
+ * collide, and it is the skeleton's own ordering rather than a number invented
462
+ * here.
463
+ */
464
+ function skinDirName(name: string, index: number): string {
465
+ return `${index}-${name.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
466
+ }
467
+
468
+ /**
469
+ * Step 5's plan: one block per declared skin, or exactly one block when the
470
+ * skeleton declares no skin at all.
471
+ *
472
+ * ⭐ The no-skin case keeps the old paths (`render-build`, `render-export`,
473
+ * `check.json`) because for such a skeleton there is nothing to distinguish, and
474
+ * a run whose output moved would be a run whose ledgers all have to be re-read
475
+ * for no measurement gained. A skinned skeleton files each block under its own
476
+ * directory, so the frames of two skins can never overwrite each other — which
477
+ * is the failure that would turn "one block per skin" back into one block.
478
+ */
479
+ export function skinBlocks(skins: string[], out: string, fps: number): SkinBlock[] {
480
+ if (skins.length === 0) {
481
+ return [
482
+ {
483
+ skin: null,
484
+ heading: `## 5 render both @${fps}fps, check the export against the build's own frames (no skin declared)`,
485
+ args: [],
486
+ buildFrames: join(out, 'render-build'),
487
+ exportFrames: join(out, 'render-export'),
488
+ checkJson: join(out, 'check.json'),
489
+ },
490
+ ];
491
+ }
492
+ return skins.map((skin, i) => ({
493
+ skin,
494
+ heading:
495
+ `## 5.${i + 1}/${skins.length} skin "${skin}" — render both @${fps}fps, check the export against the ` +
496
+ "build's own frames",
497
+ args: ['--skin', skin],
498
+ buildFrames: join(out, 'render-build', skinDirName(skin, i)),
499
+ exportFrames: join(out, 'render-export', skinDirName(skin, i)),
500
+ checkJson: join(out, `check-${skinDirName(skin, i)}.json`),
501
+ }));
502
+ }
503
+
386
504
  /** The shape of a skeleton file, for the field-by-field comparison. */
387
505
  export interface Shape {
388
506
  spine: string;
@@ -719,18 +837,48 @@ function main(): void {
719
837
  // console layout for them would break the first time that layout is tidied.
720
838
  for (const line of movedMeasures(diffJson)) emit(` ${line}`);
721
839
 
722
- emit('');
723
- emit(`## 5 render both @${opts.fps}fps, check the export against the build's own frames`);
724
- run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, '--fps', String(opts.fps), '--out', join(opts.out, 'render-build')], 900);
725
- run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, '--fps', String(opts.fps), '--out', join(opts.out, 'render-export')], 900);
726
- const check = run(
727
- rigc.cmd,
728
- [...rigc.prefix, 'check', '--candidate', cand, '--frames', join(opts.out, 'render-build'), '--json', join(opts.out, 'check.json')],
729
- 900,
730
- );
731
- emit(` exit=${check.status}`);
732
- for (const line of verdictLines(check.stdout)) emit(` ${line}`);
733
- for (const line of checkFigures(join(opts.out, 'check.json'))) emit(` ${line}`);
840
+ // 🔒 The skins come off the BUILD, which is the side under test: a skin the
841
+ // export dropped altogether then reads as a block whose check fails by name,
842
+ // where enumerating the export's own skins would quietly stop looking for it.
843
+ const blocks = skinBlocks(skinsDeclaredBy(source), opts.out, opts.fps);
844
+ const checks: Array<{ skin: string | null; status: number | null; mae: number | null }> = [];
845
+ for (const block of blocks) {
846
+ emit('');
847
+ emit(block.heading);
848
+ run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, '--fps', String(opts.fps), ...block.args, '--out', block.buildFrames], 900);
849
+ run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, '--fps', String(opts.fps), ...block.args, '--out', block.exportFrames], 900);
850
+ const check = run(
851
+ rigc.cmd,
852
+ [...rigc.prefix, 'check', '--candidate', cand, '--frames', block.buildFrames, ...block.args, '--json', block.checkJson],
853
+ 900,
854
+ );
855
+ emit(` exit=${check.status}`);
856
+ for (const line of verdictLines(check.stdout)) emit(` ${line}`);
857
+ for (const line of checkFigures(block.checkJson)) emit(` ${line}`);
858
+ checks.push({ skin: block.skin, status: check.status, mae: worstMeanMae(block.checkJson) });
859
+ }
860
+ // The roll-up, so a loss in one skin of many is a line somebody reads rather
861
+ // than a row buried in the block above it. The mark is on the LARGEST figure
862
+ // and only where the skins disagree: `check` has no pass mark to compare
863
+ // against and this tool does not get to invent one, but "these skins did not
864
+ // come back the same" is a fact the run itself produced.
865
+ if (blocks.length > 1) {
866
+ const figures = checks.map((c) => c.mae).filter((v): v is number => v !== null);
867
+ const worst = figures.length === 0 ? null : Math.max(...figures);
868
+ const agreed = figures.length === checks.length && new Set(figures).size === 1;
869
+ emit('');
870
+ emit(` per skin ${checks.length} block(s)`);
871
+ for (const { skin, status, mae } of checks) {
872
+ emit(
873
+ ` ${String(skin).padEnd(20)} check exit=${status} worst mean MAE ` +
874
+ `${mae === null ? '(no report)' : mae.toFixed(4)}` +
875
+ `${status === 0 ? '' : ' ⚠️ this skin did not come back'}` +
876
+ `${!agreed && mae !== null && mae === worst ? ` ⚠️ the worst of the ${checks.length} skins` : ''}`,
877
+ );
878
+ }
879
+ if (agreed) emit(` ⤷ every skin came back at the same figure, so no skin is carrying a difference the others are not.`);
880
+ }
881
+ const checksClean = checks.every((c) => c.status === 0);
734
882
 
735
883
  emit('');
736
884
  emit('## 6 what the editor rewrote');
@@ -741,15 +889,18 @@ function main(): void {
741
889
  keepLog();
742
890
  emit('');
743
891
  emit(`log: ${logPath}`);
744
- // The verdict is the gate's and the check's, not this tool's opinion of them.
745
- process.exit(gate.status === 0 && check.status === 0 ? 0 : 1);
892
+ // The verdict is the gate's and EVERY skin's check, not this tool's opinion of
893
+ // them: one skin coming back wrong is the whole run coming back wrong, which
894
+ // is the half a single un-skinned check could not say.
895
+ process.exit(gate.status === 0 && checksClean ? 0 : 1);
746
896
  }
747
897
 
748
- // ⭐ Guarded so `shapeOf` and `shapeDiff` can be READ by a control that has no
749
- // editor (issue #561). Every other `ERT` case drives this file as a subprocess,
750
- // which is the right shape for a refusal; step 6's summary is a pure function of
751
- // two skeleton files, and driving an editor — or four rigc subcommands — to
752
- // reach it would be paying for a round trip to test arithmetic. Run as a
898
+ // ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
899
+ // READ by a control that has no editor (issues #561, #571). Every other `ERT`
900
+ // case drives this file as a subprocess, which is the right shape for a refusal;
901
+ // step 6's summary is a pure function of two skeleton files and step 5's plan is
902
+ // a pure function of one, and driving an editor — or four rigc subcommands — to
903
+ // reach either would be paying for a round trip to test arithmetic. Run as a
753
904
  // program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
754
905
  // the entry, so `import.meta.main` is true.
755
906
  if (import.meta.main) main();