spine-parts 0.6.0 → 0.7.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/NOTICE.md CHANGED
@@ -60,10 +60,15 @@ from them beyond the layers See-through itself writes for the user's own image.
60
60
  ## 2. Spine Runtimes, through spine-rigc
61
61
 
62
62
  spine-parts depends on [`spine-rigc`](https://www.npmjs.com/package/spine-rigc)
63
- (MIT, same author), which compiles and validates Spine skeleton data and links
64
- `@esotericsoftware/spine-core`, part of the
63
+ (MIT, same author), which compiles and validates Spine skeleton data. Its round
64
+ trip runs through `@esotericsoftware/spine-core`, part of the
65
65
  [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes), under the
66
66
  [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license).
67
+ Since spine-rigc 2.0 neither package installs the runtime: spine-rigc carries it
68
+ only as a development dependency, and an install of spine-rigc gates a build with
69
+ its own validator. spine-parts does not install it either; it is this repository's
70
+ development dependency, at the version spine-rigc develops against, for the round
71
+ trip in the selftest and CI and the selftest's posing oracle.
67
72
  spine-rigc's [NOTICE.md](https://github.com/firejune/rigc/blob/main/NOTICE.md)
68
73
  sets out the chain in full; briefly, as a restatement of Esoteric Software's
69
74
  terms and not a term of this project:
@@ -73,8 +78,11 @@ terms and not a term of this project:
73
78
  2. Playing Spine skeleton data in a product requires a Spine Runtime, and the
74
79
  Spine Runtimes License requires **each user of such a product to own a
75
80
  Spine Editor license**.
76
- 3. spine-rigc links `spine-core`, and spine-parts depends on spine-rigc, so the
77
- same obligation applies to running the stages of spine-parts that call it.
81
+ 3. spine-rigc no longer installs `spine-core`, and spine-parts does not either;
82
+ the obligation in 2 attaches to the Spine skeleton data spine-rigc writes,
83
+ which the stages of spine-parts that call it produce, and to `spine-core`
84
+ wherever it is installed beside them — this repository's development
85
+ dependency included.
78
86
 
79
87
  spine-parts also imports spine-rigc's own modules directly, by deep path: its PNG codec
80
88
  (`tools/plate.ts`, `src/png.ts`), its 5x7 label font (`tools/font5x7.ts`) and
@@ -114,10 +122,13 @@ function's comment says which call it stands in for and what was measured.
114
122
  | Package | Licence | Why |
115
123
  | --- | --- | --- |
116
124
  | [`spine-rigc`](https://www.npmjs.com/package/spine-rigc) | MIT | compile, gate, render and check; its PNG codec, font and coordinate conversion |
117
- | ↳ [`@esotericsoftware/spine-core`](https://www.npmjs.com/package/@esotericsoftware/spine-core) | Spine Runtimes License | spine-rigc's round trip (section 2) |
118
125
  | [`ag-psd`](https://www.npmjs.com/package/ag-psd) | MIT | reading an upstream See-through `.psd` |
119
126
  | ↳ [`pako`](https://www.npmjs.com/package/pako) | MIT AND Zlib | ag-psd's deflate |
120
127
  | ↳ [`base64-js`](https://www.npmjs.com/package/base64-js) | MIT | ag-psd's base64 |
121
128
 
122
129
  Development only, not installed with the package: `typescript` (Apache-2.0),
123
- `eslint` (MIT), `typescript-eslint` (MIT), `@types/bun` (MIT).
130
+ `eslint` (MIT), `typescript-eslint` (MIT), `@types/bun` (MIT), and
131
+ [`@esotericsoftware/spine-core`](https://www.npmjs.com/package/@esotericsoftware/spine-core)
132
+ (Spine Runtimes License; section 2), pinned to the version spine-rigc develops
133
+ against (`TY10`): the round trip in the selftest and CI, the selftest's posing
134
+ oracle, and `tools/atlas_population.ts` and `tools/idle_cost.ts`.
package/README.md CHANGED
@@ -35,7 +35,6 @@ was shifted down into the canvas (its proposal ran 118 px above the top edge;
35
35
  is taken from the full run, because the twin tails leave the head crop sideways
36
36
  (a plan edit). <code>check</code> printed, verbatim:<br/>
37
37
  <code>49 assertions: 23 measured (23 passed, 0 failed), 26 skipped, 0 not in profile "spine-html"</code><br/>
38
- <code>49 assertions: 14 measured (14 passed, 0 failed), 20 skipped, 15 not in profile "spine"</code><br/>
39
38
  <code>loop: idle 49 frame(s) at 12 fps, f0000 vs f0048 (t = 4s): max |d| 0 (0 required)</code><br/>
40
39
  <code>seam: setup pose at 714x1216, scale 0.9422: mean |d| 0.324 (&lt;= 1.0), 2 px over 40 (&lt;= 50), 0 px over 80 (reported)</code><br/>
41
40
  <code>pack: skeleton.png 967x1338, 22 region(s), 91.2% covered, padding 2, page edges free</code><br/>
@@ -87,7 +86,7 @@ page** (issue #2):
87
86
 
88
87
  | path under `--out` | what |
89
88
  | --- | --- |
90
- | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack --page-edges free` and gated under both profiles |
89
+ | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack --page-edges free` and gated under `spine-html`, which holds every rule `spine` measures (on the demo, rigc 1.5.1 and 2.0.3: the 14 rules `validate --profile spine` measures are among the build's 23; 15 are not in `spine`) |
91
90
  | `parts/*.png`, `parts.json`, `recomposite_rig.png`, `recomposite_error_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from, how many of its pixels were re-taken from the painting, and the recomposite's uncovered holes with their boxes; the flat stack of parts; its error map — red where no part covers a pixel the painting has, blue where a part covers it in the wrong colour |
92
91
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
93
92
  | `check/` | both gate files verbatim, the idle's frames and their `geometry.json` (skinned vertices per frame), `contact.png`, `motion_heat.png`, `check.json` |
@@ -124,7 +123,7 @@ atlas written by anything else would have no oracle behind it.
124
123
  | stage | tool | what it owns |
125
124
  | --- | --- | --- |
126
125
  | painting + See-through layers → parts and specs | **spine-parts** | the merge of two runs, the measurements, the rig spec and motion spec |
127
- | specs → Spine skeleton data | **[spine-rigc](https://github.com/firejune/rigc)** | compile, the round trip through `spine-core`, the named assertions, the packer, the renderer |
126
+ | specs → Spine skeleton data | **[spine-rigc](https://github.com/firejune/rigc)** | compile, the gate (rigc's own validator in an install; the round trip through `spine-core` where the runtime is beside it, as in this repository's CI), the named assertions, the packer, the renderer |
128
127
  | skeleton data → a page | **[spine-html](https://github.com/firejune/spine-html)**, a sibling project | a DOM renderer; rigc's `spine-html` profile is its policy, and every build here is gated under that profile as well as under `spine` |
129
128
 
130
129
  spine-parts never writes Spine data itself. Everything on disk under `check/build/`
@@ -253,8 +252,8 @@ agent skill.
253
252
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
254
253
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
255
254
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
256
- | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
257
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
255
+ | `rig --config --parts --out [--idle-keys ctl\|direct]` | author `rig.json` + `motion.json`, written only after spine-rigc's gate is green; `--idle-keys` says whether the idle's keys on mesh-driving bones go through `<bone>_ctl` parents (default) or stay on the bones with `invariants.idleDrivesMeshes` declared |
256
+ | `check --rig --out [--parts]` | build packed, gated under `spine-html`, render the idle, measure seam, loop and the six judgement lines (mesh texture stretch among them, from the idle's `render --geometry`), and report the recomposite's holes from `parts.json` |
258
257
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
259
258
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
260
259
 
@@ -319,13 +318,19 @@ the flag, puts 813 of `sample`'s 993 still-unprojected pixels and 879 of `demo`'
319
318
 
320
319
  [Bun](https://bun.sh) 1.2 or later. `npm install -g spine-parts` installs the
321
320
  `spine-parts` command; it hands off to Bun and says so in one sentence if Bun is not
322
- on `PATH`. spine-rigc comes with it as a dependency.
321
+ on `PATH`. spine-rigc comes with it as a dependency. No Spine runtime does: since
322
+ spine-rigc 2.0 the installed `rigc` gates every build with its own validator over the
323
+ compiled model document, and `rigc --version` says `entry: cli_core.ts —
324
+ @esotericsoftware/spine-core absent`. With `@esotericsoftware/spine-core` installed
325
+ beside it, the same `rigc` runs the spine-core round trip instead, as this repository's
326
+ selftest and CI do; `check.json`'s `rigc_entry` records which one gated a build.
323
327
 
324
328
  ## Licence posture
325
329
 
326
- spine-parts is MIT, and it depends on spine-rigc, which links Esoteric Software's
327
- `spine-core`: using what it produces in a product requires a Spine Editor licence, as
328
- any Spine data does. [NOTICE.md](NOTICE.md) records that chain and every other
330
+ spine-parts is MIT, and it depends on spine-rigc, whose output is Spine skeleton data:
331
+ using what it produces in a product requires a Spine Editor licence, as any Spine data
332
+ does. Neither package installs Esoteric Software's `spine-core` any more; this
333
+ repository uses it in development, for the round trip in its selftest and CI. [NOTICE.md](NOTICE.md) records that chain and every other
329
334
  third-party term this package touches, See-through's included.
330
335
 
331
336
  ## Development
package/cli.ts CHANGED
@@ -15,7 +15,6 @@ import { spawnSync } from 'node:child_process';
15
15
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
18
  import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
19
  import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
20
  import { DEFAULT_PAGE_EDGES, findRigc, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
@@ -112,9 +111,10 @@ usage:
112
111
  CONFIG_BLINK_GROUP_UNIQUE, before rigc starts. --parts is the
113
112
  directory holding parts.json and parts/<name>.png. The result is built
114
113
  through spine-rigc (profile spine-html, packed with --page-edges as for
115
- check, then validated under profile spine) in a scratch directory first, and --out receives
116
- images/*.png, rig.json, motion.json and mesh_report.json only when both
117
- are green. Prints one line per mesh and the rigc gate lines.
114
+ check; rigc's build gates the compile and the packed pages on disk) in a
115
+ scratch directory first, and --out receives images/*.png, rig.json,
116
+ motion.json and mesh_report.json only when it is green. Prints one line
117
+ per mesh and the rigc gate lines.
118
118
  --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
119
119
  ctl (the default) keys a same-origin <bone>_ctl parent instead, which
120
120
  passes A15_IDLE_NO_MESH_BONE_KEYS on any spine-rigc; direct keys the bone
@@ -129,11 +129,12 @@ usage:
129
129
  ${PARTS_HOME_SENTENCE}.
130
130
  Both are only read. Into --out:
131
131
  build/ (rigc build --profile spine-html --pack --page-edges <value>: the
132
- packed atlas is the artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
132
+ packed atlas is the artifact), gate_spine-html.txt (the gate lines
133
133
  verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
134
134
  --geometry: the frames and geometry.json, the skinned vertices and
135
135
  every bone's world transform per frame),
136
- contact.png, motion_heat.png and check.json. PASS needs both gates
136
+ contact.png, motion_heat.png and check.json (with rigc_entry, the rigc
137
+ entry that gated the build). PASS needs every gate summary
137
138
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
138
139
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
139
140
  frame at t = duration) at max |d| 0. Then six judgement lines, each in
@@ -396,20 +397,6 @@ function cmdSheet(args: string[]): number {
396
397
  }
397
398
  }
398
399
 
399
- /** spine-rigc's own CLI, resolved the way this package resolves every other rigc path. */
400
- function rigcCli(): string {
401
- return fileURLToPath(import.meta.resolve('spine-rigc/cli.ts'));
402
- }
403
-
404
- /** The rig stage's rigc: spine-rigc's cli.ts run by this Bun, as the stage has always gated. */
405
- function rigGateRunner(): RigcRunner {
406
- return (args) => {
407
- const r = spawnSync(process.execPath, [rigcCli(), ...args], { encoding: 'utf8', maxBuffer: 1 << 28 });
408
- if (r.error !== undefined) return { status: 127, out: `could not start ${rigcCli()}: ${r.error.message}` };
409
- return { status: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
410
- };
411
- }
412
-
413
400
  /** `--page-edges`, or its default; a value spine-rigc does not take is a usage error naming the two it does. */
414
401
  function pageEdgesOf(value: string | undefined): PageEdges | string {
415
402
  const edges = value ?? DEFAULT_PAGE_EDGES;
@@ -449,9 +436,15 @@ function cmdRig(args: string[]): number {
449
436
  if (!(IDLE_KEYS as readonly string[]).includes(keys)) return usage(`--idle-keys ${keys}; one of ${IDLE_KEYS.join(', ')} is required`);
450
437
  const edges = pageEdgesOf(pageEdges ?? undefined);
451
438
  if (!isPageEdges(edges)) return usage(edges);
439
+ let bin: string;
440
+ try {
441
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
442
+ } catch (err) {
443
+ return printRefusal(err);
444
+ }
452
445
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-rig-'));
453
446
  try {
454
- rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges }, rigGateRunner(), scratch, console.log);
447
+ rigStage({ config, parts: partsDir, out, idleKeys: keys as IdleKeys, pageEdges: edges }, rigcRunner(bin), scratch, console.log);
455
448
  return EXIT_OK;
456
449
  } catch (err) {
457
450
  return printRefusal(err);
@@ -460,7 +453,16 @@ function cmdRig(args: string[]): number {
460
453
  }
461
454
  }
462
455
 
463
- /** The rigc process: `src/check.ts` is pure and takes the spawn from here. */
456
+ /**
457
+ * The rigc process, for every stage that runs rigc — `rig`'s gate, `check`, and
458
+ * both inside `build`: the `rigc` binary {@link findRigc} located, spawned as
459
+ * it is, so spine-rigc's own launcher (`bin/rigc.cjs`) chooses the entry —
460
+ * `cli.ts` and the spine-core round trip where the runtime resolves beside it,
461
+ * `cli_core.ts` and rigc's own validator where it does not. No stage runs a
462
+ * rigc source file by path: under spine-rigc 2.0, `cli.ts` by path is the
463
+ * spine-core entry whether or not spine-core is there. `src/` is pure and takes
464
+ * the spawn from here.
465
+ */
464
466
  function rigcRunner(bin: string): RigcRunner {
465
467
  return (args) => {
466
468
  const r = spawnSync(bin, [...args], { encoding: 'utf8', maxBuffer: 1 << 28 });
@@ -830,7 +832,7 @@ function cmdBuild(args: string[]): number {
830
832
  try {
831
833
  const r = build(
832
834
  { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges },
833
- { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
835
+ { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
834
836
  console.log,
835
837
  );
836
838
  return r.stoppedAt === null ? EXIT_OK : EXIT_REFUSED;
package/docs/AUTHORING.md CHANGED
@@ -18,7 +18,7 @@ into `examples/sample/inputs/`.
18
18
  | what | why | how |
19
19
  | --- | --- | --- |
20
20
  | [Bun](https://bun.sh) 1.2 or later | spine-parts and spine-rigc are Bun programs | `npm install -g spine-parts` installs the command; it says so if Bun is missing |
21
- | [spine-rigc](https://www.npmjs.com/package/spine-rigc) | compiles, gates, packs and renders every rig; spine-parts writes no Spine data itself | installed with spine-parts as a dependency (`node_modules/.bin/rigc`) |
21
+ | [spine-rigc](https://www.npmjs.com/package/spine-rigc) | compiles, gates, packs and renders every rig; spine-parts writes no Spine data itself | installed with spine-parts as a dependency (`node_modules/.bin/rigc`); with no Spine runtime beside it, it gates with its own validator, and `rigc --version` says `entry: cli_core.ts` |
22
22
  | [See-through](https://github.com/shitagaki-lab/see-through), somewhere | the layer decomposition is the input | any route in README *Getting See-through layers*; it runs twice per character, outside this tool |
23
23
  | a GPU, wherever See-through runs | See-through is a diffusion model | nothing in spine-parts itself uses a GPU |
24
24
 
@@ -266,7 +266,7 @@ proposal in as the steps above say (`RL01`); every step must exit 0.
266
266
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
267
267
  | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px`, then `uncovered holes (8-connected): N` and the largest five as `uncovered hole K: <px> px at x,y wxh (between "<part>" <px> px, …)`; and look at `render/recomposite_error_rig.png` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185, 259 holes, the largest 94 px; `demo` 2.39 / 95.8 % / 11,050 / 1,564, 453 holes, the largest 70 px (default rule) — many slivers along part edges, no hole a region could hide | one large hole: part of the figure is in no layer — a plan entry is missing, hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason), or See-through split one garment into two and left the space between them in neither (a skirt as two trouser legs); the `between` parts say where. When neither run holds it at all, an `assemble.patches` entry cuts it from the painting (below) |
268
268
  | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art, and the hip is below the chest and the figure's top quarter | a link off the art (a bone on the background) — move it onto the layer; `LINT hip at [x, y] is not below chest at [x, y]: …` or `LINT hip at [x, y] is above 0.25 of the figure height (figure y T..B, so hip y must be at least L): a hip at the shoulders` — move `hip` down to the waist (and `chest` between it and the neck); a `hanging strand … -- no chain proposed` note — that strand hangs stiff until you add a chain down the x and rows it names (§3); a `no blink: …` or `blink without brows: …` note — the idle will not blink (or its brows will not drop), because no part came from an `eyewhite` (or `eyebrow`) layer; the note names the tags looked for; a `no eyewhite part for eye_<s> …` note — the iris and lash parts it names ride `head` and do not blink, because no `eyewhite-<s>` part made that side's eye bone: take the eyewhite from the other run, or keep them on `head` |
269
- | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
269
+ | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; the `bones` line; the `idle keys` line; then rigc's gate lines (with A15's declared SKIP under `--idle-keys direct`) | `cover 1.00000` on every mesh, both gate summaries `0 failed` (the compile and the packed pages) | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
270
270
  | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the six judgement lines and `RECOMPOSITE_HOLES` (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
271
271
  | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
272
272
 
@@ -301,7 +301,9 @@ the reference's so the examples stay comparable with it.
301
301
  the least-area page the parts need. `--page-edges pot` is the opt-out: a power of
302
302
  two on both edges, which is the page every build wrote before this flag. The pack
303
303
  line says which one ran, because rigc ends it with `, page edges free` under `free`.
304
- Measured with rigc 1.5.1 on the two examples, by `tools/atlas_population.ts`:
304
+ Measured on the two examples by `tools/atlas_population.ts` with rigc 1.5.1, and again
305
+ with rigc 2.0.3, which reproduced every figure in both tables and the one check figure
306
+ below:
305
307
 
306
308
  | build | page | covered | page opaque | page / figure |
307
309
  | --- | --- | --- | --- | --- |
@@ -357,8 +359,8 @@ differ by at most one level in a channel. The default stays `ctl`: the examples'
357
359
  `expected/rig.json` and `motion.json` are the reference's output, and the
358
360
  declaration is in the rig spec only, so `rigc validate <build> --profile spine-html`
359
361
  run on a `direct` build (it has no rig spec to read) refuses A15 once per keyed
360
- bone. `check` validates the build under `spine` only, where A15 is not in the
361
- profile, and is green on both.
362
+ bone. No stage runs `validate`: the gate is `rigc build`, which reads the rig spec
363
+ and its declaration, and is green on both (`RG19`).
362
364
 
363
365
  **Patching a hole no layer holds.** When an `uncovered hole` stays large after the
364
366
  plan is right — both See-through runs dropped a piece of the figure, red in
@@ -588,7 +590,8 @@ See-through with another seed.
588
590
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
589
591
  | `CHECK_INPUT_PRESENT` on `…/parts.json` | `--parts` named a directory without `parts.json` — most often `parts/` itself. The detail says it: "--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build's --out, whose rig is <out>/rig); without --parts it is --rig", and names the parent when the parent holds `parts.json` | `--parts` — the directory above `parts/` |
590
592
  | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by) | re-run rig (`build` does both) |
591
- | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^1.5.1), or put a newer `rigc` first on `PATH` |
593
+ | `CHECK_RIGC_VERSION` | `rigc --version` is below 1.4.0, or prints no version: its `render` has no `--geometry`, which `TEXTURE_STRETCH` reads. Refused before anything is built | `bun install` (this package depends on spine-rigc ^2.0.3), or put a newer `rigc` first on `PATH` |
594
+ | `CHECK_RIGC_ENTRY`, `CHECK_RIGC_ENTRY_READS` | `rigc --version` names no entry (a rigc below 2.0.0), or names one in neither launcher form (`entry: cli.ts — @esotericsoftware/spine-core <v> present`, `entry: cli_core.ts — @esotericsoftware/spine-core absent — …`); `check.json`'s `rigc_entry` records the one that gated the build | `bun install` |
592
595
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
593
596
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
594
597
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted) | a track whose last key is not its first |
@@ -611,14 +614,17 @@ See-through with another seed.
611
614
  implementation's bars):
612
615
 
613
616
  - `gate_spine-html.txt`: every summary line `(N passed, 0 failed)` — the packed build
614
- under the `spine-html` profile;
615
- - `gate_spine.txt`: the same, `rigc validate` of that build under the `spine` profile;
617
+ under the `spine-html` profile, gated once over the compile and once over the packed
618
+ pages on disk. There is no `spine` gate of its own: `spine-html` holds every rule
619
+ `spine` measures (on the demo, rigc 1.5.1 and 2.0.3: `validate --profile spine`'s 14
620
+ measured rules are all among the build's 23, the 15 others not in `spine`; `CH09`
621
+ holds it on every fetched example);
616
622
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
617
623
  max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40;
618
624
  - **loop**: idle frame 0 against the frame at `t = duration`: max |d| exactly 0.
619
625
 
620
- On the examples: `sample` 23/23 and 14/14, seam 0.207 with 0 pixels over 40, loop 0;
621
- `demo` (default rule) 23/23 and 14/14, seam 0.326 with 2 pixels over 40, loop 0.
626
+ On the examples: `sample` 23/23, seam 0.207 with 0 pixels over 40, loop 0;
627
+ `demo` (default rule) 23/23, seam 0.326 with 2 pixels over 40, loop 0.
622
628
 
623
629
  A green gate cannot see a wrong animation, so `check` also writes six **judgement
624
630
  lines** (issue #11; `TEXTURE_STRETCH`, issue #31), each a key of `check.json` and a console line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "AI-authored Spine 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through spine-rigc before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,9 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "ag-psd": "^31.0.2",
60
- "spine-rigc": "^1.5.1"
60
+ "spine-rigc": "^2.0.3"
61
61
  },
62
62
  "devDependencies": {
63
+ "@esotericsoftware/spine-core": "4.3.13",
63
64
  "@types/bun": "^1.4.2",
64
65
  "eslint": "^10.11.0",
65
66
  "typescript": "^5.9.3",
@@ -15,9 +15,11 @@ stage writes are the whole interface.
15
15
 
16
16
  ## Non-negotiables
17
17
 
18
- - **spine-rigc's round trip is never bypassed.** spine-parts writes no Spine data
19
- itself; `rig` writes only after rigc's build and validate are green, and `build`'s
18
+ - **spine-rigc's gate is never bypassed.** spine-parts writes no Spine data
19
+ itself; `rig` writes only after rigc's `build` gate is green, and `build`'s
20
20
  artifact is what rigc wrote. There is no flag that skips it, and none may be added.
21
+ `check.json`'s `rigc_entry` names which gate ran: rigc's own validator in an
22
+ install, the spine-core round trip where the runtime is installed beside rigc.
21
23
  - **No value is invented.** A missing config field is a refusal naming it. Take a
22
24
  value from the command that proposes it (`propose --head-box`,
23
25
  `assemble --propose-plan`, `propose`) or decide it yourself — never guess one
package/src/build.ts CHANGED
@@ -44,7 +44,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:
44
44
  import { dirname, join } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
46
  import { assemble, type AssembleResult, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
47
- import { type CheckReport, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
47
+ import { causeLines, type CheckReport, type PackLine, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK } from './check.ts';
48
48
  import { loadConfig, loadEarlyConfig } from './config.ts';
49
49
  import { PartsError, type Problem, problemLine } from './errors.ts';
50
50
  import { encodeGif } from './gif.ts';
@@ -162,7 +162,12 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
162
162
  interface GateRun {
163
163
  label: string;
164
164
  status: number;
165
- /** rigc's FAIL lines, its assertion summary and the one SKIP this stage's own declaration causes, as it printed them. */
165
+ /**
166
+ * rigc's FAIL lines, its assertion summary, the core entry's `here:` line and
167
+ * the one SKIP this stage's own declaration causes, as it printed them — or,
168
+ * for a run that exited non-zero with none of those, the lines
169
+ * {@link causeLines} says name why.
170
+ */
166
171
  lines: string[];
167
172
  }
168
173
 
@@ -175,18 +180,24 @@ interface GateRun {
175
180
  */
176
181
  const DECLARED_SKIP = /^ {2}SKIP {2}A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig/;
177
182
 
183
+ /** The core entry's line after its summary (spine-rigc 2.0.0 and later): which rules ran, and that the round trip did not. */
184
+ const HERE_LINE = /^ {2}\.\. {4}here: /;
185
+
178
186
  function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
179
187
  const r = rigc(args);
180
- const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l));
181
- return { label, status: r.status, lines };
188
+ const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
189
+ const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
190
+ return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines };
182
191
  }
183
192
 
184
193
  /**
185
- * spine-rigc's round trip over the rig in a scratch directory: `build` under
186
- * the spine-html profile with `--pack --page-edges <edges>`, then `validate`
187
- * of that build under the spine profile. The files are staged exactly as `--out` will receive
188
- * them, so what passed is what is written. The scratch directory is the
189
- * caller's, and is emptied here before and after.
194
+ * spine-rigc's gate over the rig in a scratch directory: `build` under the
195
+ * spine-html profile with `--pack --page-edges <edges>`, which runs the gate
196
+ * once over the compile and once over the packed pages on disk. There is no
197
+ * second `validate --profile spine` run: spine-html holds every rule spine
198
+ * measures (selftest `CH09`). The files are staged exactly as `--out` will
199
+ * receive them, so what passed is what is written. The scratch directory is
200
+ * the caller's, and is emptied here before and after.
190
201
  */
191
202
  function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: RigcRunner, scratch: string, edges: PageEdges): GateRun[] {
192
203
  rmSync(scratch, { recursive: true, force: true });
@@ -195,9 +206,7 @@ function gateThroughRigc(out: RigOutput, texts: Array<[string, string]>, rigc: R
195
206
  for (const [file, img] of out.images) writeFileSync(join(scratch, 'images', file), encodePngBytes(img));
196
207
  for (const [file, text] of texts) writeFileSync(join(scratch, file), text);
197
208
  const build = join(scratch, 'build');
198
- const html = gateRun(packedBuildLabel(edges), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(edges)]);
199
- if (html.status !== 0) return [html];
200
- return [html, gateRun('validate --profile spine', rigc, ['validate', build, '--profile', 'spine'])];
209
+ return [gateRun(packedBuildLabel(edges), rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...packedBuildArgs(edges)])];
201
210
  } finally {
202
211
  rmSync(scratch, { recursive: true, force: true });
203
212
  }
@@ -214,7 +223,7 @@ export interface RigStageInput {
214
223
  pageEdges?: PageEdges;
215
224
  }
216
225
 
217
- /** Author the rig, gate it through rigc in `scratch`, and write `out` only when both gates are green. */
226
+ /** Author the rig, gate it through rigc in `scratch`, and write `out` only when the gate is green. */
218
227
  export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
219
228
  const cfg = loadConfig(input.config);
220
229
  const parts = readParts(join(input.parts, 'parts.json'));
@@ -259,7 +268,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
259
268
  const problems: Problem[] = red.map((g) => ({
260
269
  code: 'RIG_RIGC_GREEN',
261
270
  object: `rigc ${g.label}`,
262
- detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}; exit 0 is required before anything is written, and nothing was`,
271
+ detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; exit 0 is required before anything is written, and nothing was`,
263
272
  }));
264
273
  throw new PartsError(problems);
265
274
  }
@@ -316,13 +325,13 @@ export function judgementLine(name: string, line: JudgementLine | ReportedLine):
316
325
  export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string, log: Log): CheckReport {
317
326
  const v = rigc(['--version']);
318
327
  log(`spine-parts check: ${input.rig} -> ${input.out}`);
319
- log(` rigc ${v.out.trim().split('\n')[0]} at ${bin}`);
328
+ const versionLines = v.out.trim().split('\n');
329
+ const entryLine = versionLines.find((l) => l.startsWith('entry:'));
330
+ log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
320
331
  const edges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
321
332
  const r = runCheck(input.rig, input.out, rigc, input.parts, edges);
322
333
  log(` gate spine-html (rigc ${packedBuildLabel(edges)}), verbatim:`);
323
334
  for (const l of r.gateHtml) log(l);
324
- log(' gate spine (rigc validate build --profile spine), verbatim:');
325
- for (const l of r.gateSpine) log(l);
326
335
  for (const l of packLines(r)) log(` ${l}`);
327
336
  const fig = r.figures;
328
337
  log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
@@ -332,7 +341,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
332
341
  );
333
342
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
334
343
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
335
- log(` gates: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'}, spine ${fig.gate_spine_green ? 'green' : 'RED'}`);
344
+ log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
336
345
  log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/, ${join(input.out, 'idle_frames')}/`);
337
346
  for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
338
347
  log(fig.PASS ? 'check: PASS' : `check: FAIL — ${r.problems.length} bar(s) not met`);
@@ -434,7 +443,12 @@ export interface BuildInput {
434
443
  pageEdges: PageEdges;
435
444
  }
436
445
 
437
- /** The two rigc processes, as each command runs its own: `rig` gates through one, `check` measures through the other. */
446
+ /**
447
+ * The rigc process for each stage: `rig` gates through one, `check` measures
448
+ * through the other. `cli.ts` hands both the same runner, the `rigc` binary
449
+ * spine-rigc's launcher answers for; they are two fields so a caller can tell
450
+ * the stages' calls apart (the selftest counts them).
451
+ */
438
452
  export interface BuildRunners {
439
453
  rig: RigcRunner;
440
454
  check: RigcRunner;
@@ -457,20 +471,28 @@ export interface BuildResult {
457
471
  artifact: string[];
458
472
  }
459
473
 
474
+ /**
475
+ * rigc's own record of the compiled rig, which `build` writes beside the
476
+ * skeleton JSON and atlas (spine-rigc's AUTHORING, the `--out` row; written
477
+ * since 1.6): what rigc's posing core reads, not a Spine file, so it is not the
478
+ * artifact and is not counted as a second skeleton JSON.
479
+ */
480
+ export const RIGC_MODEL_DOCUMENT = 'skeleton.model.json';
481
+
460
482
  /** The three artifact files of a packed build, each refused by name when absent. */
461
- function artifactPaths(buildDir: string, report: CheckReport): string[] {
483
+ export function artifactPaths(buildDir: string, pack: readonly PackLine[]): string[] {
462
484
  const problems: Problem[] = [];
463
485
  const names = existsSync(buildDir) ? readdirSync(buildDir).sort() : [];
464
- const json = names.filter((n) => n.endsWith('.json'));
486
+ const json = names.filter((n) => n.endsWith('.json') && n !== RIGC_MODEL_DOCUMENT);
465
487
  const atlas = names.filter((n) => n.endsWith('.atlas'));
466
- if (json.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} skeleton JSON`, detail: `holds ${json.length} .json file(s) [${json.join(', ')}]; the packed build writes exactly one` });
488
+ if (json.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} skeleton JSON`, detail: `holds ${json.length} .json file(s) [${json.join(', ')}]; the packed build writes exactly one besides rigc's ${RIGC_MODEL_DOCUMENT}` });
467
489
  if (atlas.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} atlas`, detail: `holds ${atlas.length} .atlas file(s) [${atlas.join(', ')}]; the packed build writes exactly one` });
468
- if (report.pack.length === 0) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: 'rigc printed no pack line, so no packed page is named; the build runs with --pack and must print one' });
469
- for (const p of report.pack) {
490
+ if (pack.length === 0) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: 'rigc printed no pack line, so no packed page is named; the build runs with --pack and must print one' });
491
+ for (const p of pack) {
470
492
  if (!names.includes(p.page)) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: `the pack line names ${p.page}, which is not on disk` });
471
493
  }
472
494
  if (problems.length > 0) throw new PartsError(problems);
473
- return [join(buildDir, json[0]), join(buildDir, atlas[0]), ...report.pack.map((p) => join(buildDir, p.page))];
495
+ return [join(buildDir, json[0]), join(buildDir, atlas[0]), ...pack.map((p) => join(buildDir, p.page))];
474
496
  }
475
497
 
476
498
  /**
@@ -542,7 +564,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
542
564
 
543
565
  let artifact: string[];
544
566
  try {
545
- artifact = artifactPaths(join(out, 'check', 'build'), report);
567
+ artifact = artifactPaths(join(out, 'check', 'build'), report.pack);
546
568
  } catch (err) {
547
569
  return refused('artifact', err, report);
548
570
  }
package/src/check.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  *
7
7
  * ⭐ **This is the one stage that drives a subprocess, and it does not own the
8
8
  * spawn.** Every rigc step — build, validate, render — is spine-rigc's own CLI,
9
- * never a re-implementation of it, because rigc's round trip through
10
- * `spine-core` is the only oracle behind a skeleton (CLAUDE.md, "spine-rigc's
11
- * validation is not optional here either"). But `src/` is held to no child
9
+ * never a re-implementation of it, because rigc's gate is the only oracle
10
+ * behind a skeleton (CLAUDE.md, "spine-rigc's gate is not optional here
11
+ * either"): the spine-core round trip where the runtime resolves beside rigc,
12
+ * rigc's own validator where it does not, and `check.json`'s `rigc_entry` says
13
+ * which ({@link readRigcEntry}). But `src/` is held to no child
12
14
  * processes (`TY06`, with `src/comfy/` its one named exception), so the process
13
15
  * is injected: `runCheck` takes a {@link RigcRunner}, `cli.ts` hands it one
14
16
  * that spawns the binary {@link findRigc} located, and the selftest hands it
@@ -24,8 +26,7 @@
24
26
  * | output | what |
25
27
  * | --- | --- |
26
28
  * | `build/` | `rigc build --profile spine-html --pack --page-edges free` (or `pot`, when the caller names it; {@link PAGE_EDGES}) — the packed atlas is the final artifact |
27
- * | `gate_spine-html.txt` | that build's gate lines, verbatim |
28
- * | `gate_spine.txt` | `rigc validate build/ --profile spine`'s gate lines, verbatim |
29
+ * | `gate_spine-html.txt` | that build's gate lines, verbatim — the one gate: rigc's `build` runs it once over the compile and once over the packed pages on disk, and `spine-html` holds every rule `spine` measures (selftest `CH09`) |
29
30
  * | `idle_frames/` | `rigc render --animation idle --fps 12 --max 640 --geometry`: `frames.json` + `idle/f*.png` + `idle/geometry.json` (each mesh's skinned vertices, which `TEXTURE_STRETCH` reads, and every bone's world transform, which the face half of `STILL_REGIONS_DARK` reads) |
30
31
  * | `contact.png` | rigc's own contact sheet of that render, copied out |
31
32
  * | `motion_heat.png` | frame 0 in grey with each pixel's largest change across the idle in red |
@@ -195,7 +196,7 @@ export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha >
195
196
  * `free` is the default here although rigc's own default is `pot`: the packed
196
197
  * page is this package's final artifact, and on the two public examples `free`
197
198
  * takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
198
- * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1. The cost was
199
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1 and 2.0.3 alike. The cost was
199
200
  * measured on the same two builds. Each of the 49 idle frames differs from the
200
201
  * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
201
202
  * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
@@ -231,6 +232,81 @@ export interface RigcCall {
231
232
 
232
233
  export type RigcRunner = (args: readonly string[]) => RigcCall;
233
234
 
235
+ /** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
236
+ const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
237
+
238
+ /**
239
+ * What a rigc run that exited non-zero said about why, for a refusal whose own
240
+ * filter (FAIL lines, the assertion summary) matched nothing: every line that
241
+ * names a cause, or, when none does, the last three non-empty lines. Measured
242
+ * against spine-rigc 2.0.3: its `cli.ts` run by path without
243
+ * `@esotericsoftware/spine-core` beside it dies at import with one
244
+ * `error: Cannot find module '@esotericsoftware/spine-core' from …` line, and its
245
+ * core entry refuses `validate` with one `rigc validate: …` line, neither of
246
+ * which a gate filter reads. The last lines and not the first are the fallback
247
+ * because `rigc build` prints its header and inputs before it can stop. Empty
248
+ * only when the run printed nothing at all.
249
+ */
250
+ export function causeLines(out: string): string[] {
251
+ const lines = out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
252
+ const named = lines.filter((l) => CAUSE.test(l));
253
+ return named.length > 0 ? named : lines.slice(-3);
254
+ }
255
+
256
+ /** The two entries spine-rigc's launcher (`bin/rigc.cjs`, spine-rigc 2.0.0 and later) runs. */
257
+ export const RIGC_ENTRIES = ['cli.ts', 'cli_core.ts'] as const;
258
+ export type RigcEntry = (typeof RIGC_ENTRIES)[number];
259
+
260
+ /** The first spine-rigc whose `--version` names the entry that ran (its launcher's stderr line; spine-rigc 2.0.0's changelog, issue #1061 there). */
261
+ export const RIGC_ENTRY_VERSION = '2.0.0';
262
+
263
+ /**
264
+ * Which rigc entry gated the build, as `check.json` records it: `cli.ts`, the
265
+ * spine-core round trip, with the runtime's version; `cli_core.ts`, rigc's own
266
+ * validator over its model document, with no runtime (`spine_core` null).
267
+ */
268
+ export interface RigcEntryRecord {
269
+ entry: RigcEntry;
270
+ spine_core: string | null;
271
+ }
272
+
273
+ /** The launcher's two `entry:` lines, whole, as spine-rigc 2.0.3's `bin/rigc.cjs` writes them. */
274
+ const ENTRY_FULL = /^entry: cli\.ts — @esotericsoftware\/spine-core (\S+) present$/;
275
+ const ENTRY_CORE = /^entry: cli_core\.ts — @esotericsoftware\/spine-core absent — /;
276
+
277
+ /**
278
+ * The entry a `rigc --version` run names. A run with no `entry:` line is a
279
+ * rigc older than {@link RIGC_ENTRY_VERSION}, or not rigc's launcher, and is
280
+ * refused `CHECK_RIGC_ENTRY`; an `entry:` line in neither form above is refused
281
+ * `CHECK_RIGC_ENTRY_READS`, naming the line — a third entry is not read as
282
+ * either of the two.
283
+ */
284
+ export function readRigcEntry(versionOut: string): RigcEntryRecord {
285
+ const lines = versionOut.split('\n').map((l) => l.trim());
286
+ const line = lines.find((l) => l.startsWith('entry:'));
287
+ if (line === undefined) {
288
+ refuseIfAny([
289
+ {
290
+ code: 'CHECK_RIGC_ENTRY',
291
+ object: '`rigc --version`',
292
+ detail: `printed ${JSON.stringify(versionOut.trim())} and no \`entry:\` line; spine-rigc ${RIGC_ENTRY_VERSION} or later is required — its launcher names the entry that ran, and check.json records which one gated the build (\`bun install\` puts this package's own spine-rigc at node_modules/.bin/rigc)`,
293
+ },
294
+ ]);
295
+ }
296
+ const at = line as string;
297
+ const full = ENTRY_FULL.exec(at);
298
+ if (full !== null) return { entry: 'cli.ts', spine_core: full[1] };
299
+ if (ENTRY_CORE.test(at)) return { entry: 'cli_core.ts', spine_core: null };
300
+ refuseIfAny([
301
+ {
302
+ code: 'CHECK_RIGC_ENTRY_READS',
303
+ object: `rigc's entry line ${JSON.stringify(at)}`,
304
+ detail: 'reads as neither "entry: cli.ts — @esotericsoftware/spine-core <version> present" nor "entry: cli_core.ts — @esotericsoftware/spine-core absent — …"; one of the two is required',
305
+ },
306
+ ]);
307
+ throw new Error('unreachable');
308
+ }
309
+
234
310
  /**
235
311
  * Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
236
312
  * directory above it (a clone has it beside the package; an install has it in
@@ -427,14 +503,34 @@ export function readCheckInputs(rigDir: string, partsHome: string = rigDir): Che
427
503
  // gate lines
428
504
  // ---------------------------------------------------------------------------
429
505
 
430
- /** `check_rig.py`'s filter for the build: summary, stats and pack lines, and anything that failed. */
506
+ /**
507
+ * The `here:` line spine-rigc's core entry (`cli_core.ts`, 2.0.0 and later)
508
+ * prints after its assertion summary, saying which rules ran and that the
509
+ * spine-core round trip did not; `cli.ts` prints none.
510
+ */
511
+ const HERE_LINE = /^\.\.\s+here: /;
512
+
513
+ /**
514
+ * `check_rig.py`'s filter for the build: summary, stats and pack lines, and
515
+ * anything that failed — and, under rigc's core entry, its `here:` line, so the
516
+ * gate file says which validator ran.
517
+ */
431
518
  export function buildGateLines(out: string): string[] {
432
- return out.split('\n').filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:'))) || l.includes('FAIL') || l.includes('compile error'));
519
+ return out
520
+ .split('\n')
521
+ .filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'));
433
522
  }
434
523
 
435
- /** `check_rig.py`'s filter for validate: the summary and anything that failed. */
436
- export function validateGateLines(out: string): string[] {
437
- return out.split('\n').filter((l) => l.includes(' assertions: ') || l.includes('FAIL'));
524
+ /**
525
+ * The rules a rigc run measured — every `PASS` and `FAIL` line's rule name, in
526
+ * the order printed, each once. A SKIP, a PROF line and a summary are not
527
+ * measurements. The selftest's `CH09` compares a build's set with
528
+ * `rigc validate --profile spine`'s on the same build.
529
+ */
530
+ export function measuredRules(out: string): string[] {
531
+ const seen: string[] = [];
532
+ for (const m of out.matchAll(/^ {2}(?:PASS|FAIL) {2}([A-Z][A-Z0-9_]*)/gm)) if (!seen.includes(m[1])) seen.push(m[1]);
533
+ return seen;
438
534
  }
439
535
 
440
536
  const SUMMARY = /\((\d+) passed, (\d+) failed\)/;
@@ -466,7 +562,7 @@ const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% cove
466
562
 
467
563
  /**
468
564
  * Every pack line among the gate lines — a line that reads `pack:` once its
469
- * `..` gutter is taken off. A pack line spine-rigc 1.5.1 would not print (an
565
+ * `..` gutter is taken off. A pack line spine-rigc 1.5.1 to 2.0.3 would not print (an
470
566
  * unknown suffix, a missing field) is refused, `CHECK_PACK_LINE_READS`, naming
471
567
  * the line and the form required: a line half-read would lose the one field
472
568
  * that changed.
@@ -787,7 +883,8 @@ export const REPORTED_LINES = ['RECOMPOSITE_HOLES'] as const;
787
883
  /** check.json — the reference's fields in its order, then the judgement lines, then PASS. */
788
884
  export interface CheckFigures {
789
885
  gate_spine_html_green: boolean;
790
- gate_spine_green: boolean;
886
+ /** Which rigc entry gated the build ({@link readRigcEntry}): provenance, not a bar. */
887
+ rigc_entry: RigcEntryRecord;
791
888
  loop_max_diff: number;
792
889
  seam_mean: number;
793
890
  seam_px_over_40: number;
@@ -805,7 +902,6 @@ export interface CheckFigures {
805
902
  export interface CheckReport {
806
903
  figures: CheckFigures;
807
904
  gateHtml: string[];
808
- gateSpine: string[];
809
905
  pack: PackLine[];
810
906
  /** Opaque share (alpha > 0) of each packed page, by page file name. */
811
907
  packOpaque: Array<{ page: string; share: number }>;
@@ -816,10 +912,17 @@ export interface CheckReport {
816
912
  written: string[];
817
913
  }
818
914
 
819
- function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
915
+ /**
916
+ * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
917
+ * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
918
+ * A run that printed nothing is still one problem, never none.
919
+ */
920
+ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
820
921
  const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
821
- const quoted = named.length > 0 ? named : call.out.split('\n').filter((l) => l.trim() !== '').slice(-3);
822
- return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object: `\`rigc ${what}\``, detail: `exit ${call.status}: ${l.trim()}` }));
922
+ const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
923
+ const object = `\`rigc ${what}\``;
924
+ if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
925
+ return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
823
926
  }
824
927
 
825
928
  /**
@@ -834,7 +937,7 @@ function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Pro
834
937
  */
835
938
  export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome: string = rigDir, pageEdges: PageEdges = DEFAULT_PAGE_EDGES): CheckReport {
836
939
  const inp = readCheckInputs(rigDir, partsHome);
837
- requireRigcVersion(rigc);
940
+ const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
838
941
  const out = resolve(outDir);
839
942
  mkdirSync(out, { recursive: true });
840
943
  const buildDir = join(out, 'build');
@@ -857,12 +960,10 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
857
960
  refuseIfAny(packEdgeProblems(pack, pageEdges));
858
961
  const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
859
962
 
860
- // 2. validate under the validity profile
861
- const validate = rigc(['validate', buildDir, '--profile', 'spine']);
862
- const gateSpine = validateGateLines(validate.out);
863
- write('gate_spine.txt', `${gateSpine.join('\n')}\n`);
963
+ // 2. no second gate: `rigc validate --profile spine` of this build measured no rule the build's own two passes
964
+ // had not (14 of the build's 23 on the demo, rigc 1.5.1 and 2.0.3 alike; selftest CH09 holds it), and on rigc's
965
+ // core entry — an install without spine-core — `validate` is refused outright
864
966
  const htmlGreen = gateGreen(build.status, gateHtml);
865
- const spineGreen = gateGreen(validate.status, gateSpine);
866
967
 
867
968
  // 3. the idle, the loop, the heat
868
969
  const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
@@ -978,7 +1079,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
978
1079
 
979
1080
  const figures: CheckFigures = {
980
1081
  gate_spine_html_green: htmlGreen,
981
- gate_spine_green: spineGreen,
1082
+ rigc_entry: rigcEntry,
982
1083
  loop_max_diff: loop.max,
983
1084
  seam_mean: round3(seam.mean),
984
1085
  seam_px_over_40: seam.over40,
@@ -994,7 +1095,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
994
1095
  };
995
1096
  const barProblems: Problem[] = [];
996
1097
  if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(pageEdges), build, gateHtml));
997
- if (!spineGreen) barProblems.push(...rigcFailed('validate --profile spine', validate, gateSpine));
998
1098
  if (figures.loop_max_diff !== LOOP_MAX_BAR) {
999
1099
  barProblems.push({
1000
1100
  code: 'CHECK_LOOP_CLOSES',
@@ -1017,7 +1117,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1017
1117
  return {
1018
1118
  figures,
1019
1119
  gateHtml,
1020
- gateSpine,
1021
1120
  pack,
1022
1121
  packOpaque,
1023
1122
  idle: { frames: idle.frames.length, fps: idle.fps, duration: idle.duration, lastIndex: last.index, loopAt: { x: loop.x, y: loop.y } },
@@ -1455,10 +1554,12 @@ export function parseRigcVersion(out: string): [number, number, number] | null {
1455
1554
  /**
1456
1555
  * Refuse, before anything is built, a rigc older than
1457
1556
  * {@link RIGC_GEOMETRY_VERSION}: its `render` has no `--geometry`, so the idle
1458
- * render would fail on the flag after the build and validate had run, and
1557
+ * render would fail on the flag after the build had run, and
1459
1558
  * the refusal would name a flag rather than the version that lacks it.
1559
+ * Returns what `--version` printed, stdout then stderr, which is where
1560
+ * {@link readRigcEntry} reads the entry.
1460
1561
  */
1461
- export function requireRigcVersion(rigc: RigcRunner): void {
1562
+ export function requireRigcVersion(rigc: RigcRunner): string {
1462
1563
  const v = rigc(['--version']);
1463
1564
  const got = v.status === 0 ? parseRigcVersion(v.out) : null;
1464
1565
  const need = parseRigcVersion(RIGC_GEOMETRY_VERSION) as [number, number, number];
@@ -1472,6 +1573,7 @@ export function requireRigcVersion(rigc: RigcRunner): void {
1472
1573
  },
1473
1574
  ]);
1474
1575
  }
1576
+ return v.out;
1475
1577
  }
1476
1578
 
1477
1579
  export interface IdleGeometry {