@rig-c/parts 1.43.0 → 1.44.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/cli.ts CHANGED
@@ -31,6 +31,7 @@ import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DE
31
31
  import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
32
32
  import { BEND_DEGREES, BEND_WORST_SHARE, DUST_MAX_PX, DUST_MAX_SHARE, IDLE_BOUND_PX, MESH_DRAFT_REPORT, MESH_DRAFT_SPEC, meshDraftStage, MORE_TRIANGLES_FLAG, TOLERANCE_FIGURE_DIVISOR } from './src/meshdraft.ts';
33
33
  import { PROVENANCE_FILE, REMESH_SPEC, remeshStage } from './src/remesh.ts';
34
+ import { RETUNE_FILE, RETUNE_SPEC, retuneStage } from './src/retune.ts';
34
35
  import { checkPainting } from './src/paintcheck.ts';
35
36
  import { readPng, writePng } from './src/raster/png.ts';
36
37
  import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
@@ -384,6 +385,30 @@ usage:
384
385
  REMESH_DELTA_TOPOLOGY). No config entry is built: the baseline is the
385
386
  input itself; the splice, the gate and check are as for --mesh.
386
387
 
388
+ rig-parts retune --rig <dir> --config <config.json> --parts <dir> --out <dir>
389
+ [--requirements <file.json>] [--idle-keys ctl|direct]
390
+ [--page-edges pot|free] [--pack-shape rect|polygon]
391
+ Splice the constraints and the idle a config states onto a gated rig
392
+ (the directory rig writes) and write a new revision into --out, which
393
+ must not exist (RETUNE_OUT_FRESH); the input is never written. The
394
+ config is built once through the rig stage (RETUNE_CONFIG_REFUSED,
395
+ quoting each problem), and that build must state the input's stops,
396
+ bones, slots, skeleton, images, every invariant but detached and every
397
+ byte of motion.json but animations.idle (RETUNE_REQUEST_HELD): a moved
398
+ bone, a redrawn image, a stops change or an animations_from change is
399
+ no retune. No attachment is compared — the input's meshes are carried
400
+ over as bytes, repairs included, and a meshes change in the config is
401
+ never read (remesh is a mesh's producer). rig.json takes the build's
402
+ constraints and invariants.detached, motion.json its animations.idle,
403
+ every other byte is the input's (RETUNE_PRESERVED); rigc's build gate
404
+ passes over the revision (RETUNE_RIGC_GREEN); check measures every bar
405
+ on it that it measures on the input and fails none the input meets
406
+ (RETUNE_CHECK_HELD). --out receives the revision and ${RETUNE_FILE}
407
+ (spec "${RETUNE_SPEC}": the request, both versions, every input file's
408
+ SHA-256, what was held, which constraint fields and idle tracks changed,
409
+ both checks and their scope) — no path and no clock, so the same request
410
+ writes the same bytes. Prints what changed and a hash table of every file.
411
+
387
412
  rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
388
413
  [--page-edges pot|free] [--pack-shape rect|polygon]
389
414
  [--requirements <file.json>] [--seam-pairs] [--keep-stills]
@@ -848,6 +873,7 @@ const TAKES = {
848
873
  compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
849
874
  'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
850
875
  remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--delta', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
876
+ retune: { valued: ['--rig', '--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
851
877
  painting: { valued: ['--keypoints', '--person'], switches: [] },
852
878
  } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
853
879
 
@@ -1700,6 +1726,45 @@ function cmdRemesh(args: string[]): number {
1700
1726
  }
1701
1727
  }
1702
1728
 
1729
+ /** `retune` (issue #527): the constraints and the idle a config states, spliced onto a gated rig, into a new revision. */
1730
+ function cmdRetune(args: string[]): number {
1731
+ const f = flags(args, 'retune', ['--rig', '--config', '--parts', '--out']);
1732
+ if (typeof f === 'string') return usage(f);
1733
+ const keys = idleKeysOf(f.get('--idle-keys'));
1734
+ if (!isIdleKeys(keys)) return usage(keys);
1735
+ const edges = pageEdgesOf(f.get('--page-edges'));
1736
+ if (!isPageEdges(edges)) return usage(edges);
1737
+ const shape = packShapeOf(f.get('--pack-shape'));
1738
+ if (!isPackShape(shape)) return usage(shape);
1739
+ let bin: string;
1740
+ try {
1741
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
1742
+ } catch (err) {
1743
+ return printRefusal(err);
1744
+ }
1745
+ const runner = rigcRunner(bin);
1746
+ const said = runner(['--version']);
1747
+ const rigcVersion = said.status === 0 ? (said.out.trim().split('\n')[0] ?? '') : '';
1748
+ if (rigcVersion === '') return printRefusal(new PartsError([{ code: 'RETUNE_RIGC_GREEN', object: 'rigc --version', detail: `exited ${said.status}${rigcRanClause(said)}; the provenance names the rigc that gated the revision, so its version is required` }]));
1749
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-retune-'));
1750
+ try {
1751
+ const req = f.get('--requirements');
1752
+ retuneStage(
1753
+ { rig: f.get('--rig') as string, config: f.get('--config') as string, parts: f.get('--parts') as string, out: f.get('--out') as string, idleKeys: keys, pageEdges: edges, packShape: shape, ...(req === undefined ? {} : { requirements: req }) },
1754
+ runner,
1755
+ bin,
1756
+ { parts: version(), rigc: rigcVersion },
1757
+ scratch,
1758
+ console.log,
1759
+ );
1760
+ return EXIT_OK;
1761
+ } catch (err) {
1762
+ return printRefusal(err);
1763
+ } finally {
1764
+ rmSync(scratch, { recursive: true, force: true });
1765
+ }
1766
+ }
1767
+
1703
1768
  /**
1704
1769
  * `painting --keypoints <k.json> [--person <id>] <painting.png>` (issue #350): what the painting must be before
1705
1770
  * See-through runs on it, read off its pixels and the body-18 joints `propose --keypoints` reads. Writes nothing.
@@ -1770,6 +1835,7 @@ function main(argv: string[]): number | Promise<number> {
1770
1835
  if (command === 'compose') return cmdCompose(rest);
1771
1836
  if (command === 'mesh-draft') return cmdMeshDraft(rest);
1772
1837
  if (command === 'remesh') return cmdRemesh(rest);
1838
+ if (command === 'retune') return cmdRetune(rest);
1773
1839
  if (command === 'painting') return cmdPainting(rest);
1774
1840
  if (command === 'guide') return cmdGuide(rest);
1775
1841
  const later = LATER.find(([name]) => name === command);
package/docs/AUTHORING.md CHANGED
@@ -2979,6 +2979,38 @@ gate and `check` are the `--mesh` path's. A constraint is a statement about this
2979
2979
  revision's triangulation only: the rig spec has no field for it, so a later edit of the
2980
2980
  revision reads it as any other edge.
2981
2981
 
2982
+ ### retune
2983
+
2984
+ | rule | means | change |
2985
+ | --- | --- | --- |
2986
+ | <a id="RETUNE_OUT_FRESH"></a>`RETUNE_OUT_FRESH` | `--out` exists, or lies inside `--rig`: a revision is a new directory beside the input, never written over it | name a directory that does not exist yet |
2987
+ | <a id="RETUNE_INPUT_PRESENT"></a>`RETUNE_INPUT_PRESENT` | `--rig` is not the directory `rig` writes: `rig.json`, `motion.json`, `mesh_report.json` or `images/` is missing | point `--rig` at a `rig` output (`build`'s `rig/`, or a `remesh` or `retune` revision) |
2988
+ | <a id="RETUNE_INPUT_WRITTEN"></a>`RETUNE_INPUT_WRITTEN` | `rig.json` or `motion.json` is not the text this package's writer prints for its value (reformatted, or written by something else); the revision is spliced as text, so it could not carry every other byte over | use the files as `rig` wrote them |
2989
+ | <a id="RETUNE_CONFIG_REFUSED"></a>`RETUNE_CONFIG_REFUSED` | the rig stage refused the config as written — the loader, a mesh's own checks, an `auto` mesh's motion gate under the new idle, or rigc's gate; each problem is quoted, and nothing was written | change the field the quoted problem names |
2990
+ | <a id="RETUNE_REQUEST_HELD"></a>`RETUNE_REQUEST_HELD` | the config's build does not state the input's `stops`, `bones` (a bone moved, renamed, added, re-inherited, or a `<bone>_ctl` added or dropped), `slots`, `skeleton`, `images`, an image file, an `invariants` entry other than `detached`, or a byte of `motion.json` outside `animations.idle` (`animations_from`, the groups, the easings, the face deform); the first differing leaf is named | make it a config change and rebuild with `rig --config` |
2991
+ | <a id="RETUNE_PRESERVED"></a>`RETUNE_PRESERVED` | the spliced revision differs from the input outside `rig.json`'s `constraints` and `invariants.detached` and `motion.json`'s `animations.idle`; the file and the path that moved are named | a defect in this package: report it with the line |
2992
+ | <a id="RETUNE_RIGC_GREEN"></a>`RETUNE_RIGC_GREEN` | rigc's build gate refused the revision (its lines quoted), or `rigc --version` named no version for the provenance | read rigc's line; nothing was written |
2993
+ | <a id="RETUNE_CHECK_HELD"></a>`RETUNE_CHECK_HELD` | `check` on the revision did not measure a bar it measured on the input, or failed a bar the input met; the revision's own FAIL line is quoted | soften the constraint or the idle track the line names |
2994
+
2995
+ A retune replaces a rig's physics and its idle and nothing else. The config is built
2996
+ once through the rig stage; from that build `rig.json` takes `constraints` and
2997
+ `invariants.detached` (which the rig stage derives from the constraints), and
2998
+ `motion.json` takes `animations.idle`. Every other byte is the input's: every
2999
+ attachment and image, every `mesh_report.json` row and every other animation, so a
3000
+ mesh the config does not reproduce — a `remesh` revision, or a repair accepted after
3001
+ the config was written — is carried over as it is. What the build must state as the
3002
+ input does is held, and a config that moves it is refused rather than half-applied:
3003
+ a `stops` change (it rewires bones, slots and weights onto the drawn links
3004
+ `<link>_d`, so it is outside what a retune holds byte for byte), a moved, renamed or
3005
+ re-`inherit`ed bone, a new control bone under `--idle-keys ctl`, a redrawn image, or a
3006
+ change to `animations_from`. No mesh is compared or written, so a `meshes` change in
3007
+ the config is left out of the revision — `retune.json`'s `held.not_compared` says so;
3008
+ `remesh` is the producer of a mesh. `retune.json` names the input's SHA-256 per file,
3009
+ what was held, which constraint fields changed (and which constraints were added or
3010
+ removed), how many idle tracks changed and the idle's duration before and after, both
3011
+ versions and both checks, and quotes `check`'s own physics line as the scope of the
3012
+ revision's check.
3013
+
2982
3014
  ### rig-c's mesh refusals, quoted in a line
2983
3015
 
2984
3016
  rig-c's mesh library refuses a malformed input by throwing with a code of its own. This
package/docs/STABILITY.md CHANGED
@@ -62,6 +62,7 @@ defaults are the help's too, and none is guessed from the art.
62
62
  | `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
63
63
  | `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
64
64
  | `remesh` | `--rig`, `--config`, `--parts`, `--part`, `--out`, and one of `--mesh` or `--delta` (issue #526); optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
65
+ | `retune` | `--rig`, `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
65
66
  | `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
66
67
  | `guide` | one positional argument: a rule name, `§<n>`, `§<n>.<m>` or `§<n>.<m>.<k>` |
67
68
  | (none) | `--version`, `--help` |
@@ -199,6 +200,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
199
200
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
200
201
  | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
201
202
  | `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `remesh.json` | `remesh` | the input revision's files, `rig.json` and `mesh_report.json` with the part's attachment and row replaced (under `--delta` the row is the input's with its counts, `bones` and influences recomputed and `mode` "delta"), every other file byte for byte; `remesh.json` is spec `rig-parts-remesh/1`: `request` (`part`, `mesh`, `mesh_sha256`, `idle_keys`, `page_edges`, `pack_shape`; under `--delta`, issue #526: `part`, `delta` — the mesh edit as read — `delta_sha256`, `ops`, `applied` — `moved`, `added`, `removed`, `constrained` — `idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `baseline` (`reproduced`, `not_compared`; under `--delta` `reproduced` holds one sentence: no config is built, the input is the baseline), `target` (`before`, `after`: `vertices`, `triangles`, `hull`), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`; `scope`) |
203
+ | `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `retune.json` | `retune` | the input revision's files, `rig.json` with `constraints` and `invariants.detached` replaced and `motion.json` with `animations.idle` replaced by the config's build, every other file byte for byte; `retune.json` is spec `rig-parts-retune/1`: `request` (`idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `held` (`compared`, `not_compared`), `changed` (`constraints` — each `name`, `type`, `fields` — `added`, `removed`, `idle`: `tracks_changed`, `duration` as before and after), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`; `scope`) |
202
204
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
203
205
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
204
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/remesh.ts CHANGED
@@ -447,6 +447,12 @@ export function preservedProblems(input: FileTree, revision: FileTree, part: str
447
447
  return out;
448
448
  }
449
449
 
450
+ /** The codes the shared readings below refuse with, by the revising command that runs them (`remesh`, `retune`). */
451
+ export const REVISION_CODES = {
452
+ remesh: { inputPresent: 'REMESH_INPUT_PRESENT', inputWritten: 'REMESH_INPUT_WRITTEN', checkHeld: 'REMESH_CHECK_HELD' },
453
+ retune: { inputPresent: 'RETUNE_INPUT_PRESENT', inputWritten: 'RETUNE_INPUT_WRITTEN', checkHeld: 'RETUNE_CHECK_HELD' },
454
+ } as const;
455
+
450
456
  /** One side of the check block, read off a check report. */
451
457
  export function checkSide(r: CheckReport): CheckSide {
452
458
  return {
@@ -458,19 +464,23 @@ export function checkSide(r: CheckReport): CheckSide {
458
464
  };
459
465
  }
460
466
 
461
- /** Step 7: every bar the input measured is measured on the revision, and no bar the input met is failed by it. */
462
- export function checkHeldProblems(input: CheckSide, revision: CheckSide, problems: readonly Problem[]): Problem[] {
467
+ /**
468
+ * Step 7: every bar the input measured is measured on the revision, and no bar the input met is failed by it. `op`
469
+ * names the revising command (`retune` reuses this reading under its own code, `RETUNE_CHECK_HELD`).
470
+ */
471
+ export function checkHeldProblems(input: CheckSide, revision: CheckSide, problems: readonly Problem[], op: 'remesh' | 'retune' = 'remesh'): Problem[] {
472
+ const code = REVISION_CODES[op].checkHeld;
463
473
  const out: Problem[] = [];
464
474
  for (const bar of input.measured) {
465
475
  if (!revision.measured.includes(bar)) {
466
476
  const why = revision.skipped.find((s) => s.bar === bar)?.reason ?? 'not measured';
467
- out.push({ code: 'REMESH_CHECK_HELD', object: `check bar ${bar}`, detail: `measured on the input and not on the revision (${why}); the revision is held to every bar the input was` });
477
+ out.push({ code, object: `check bar ${bar}`, detail: `measured on the input and not on the revision (${why}); the revision is held to every bar the input was` });
468
478
  }
469
479
  }
470
480
  for (const bar of revision.not_met) {
471
481
  if (input.not_met.includes(bar)) continue;
472
482
  const quoted = problems.filter((p) => p.code.includes(bar) || p.object.includes(bar)).map(problemLine);
473
- out.push({ code: 'REMESH_CHECK_HELD', object: `check bar ${bar}`, detail: `met on the input and not on the revision${quoted.length > 0 ? `: ${quoted.join(' | ')}` : ''}; a remesh may not fail a bar its input meets` });
483
+ out.push({ code, object: `check bar ${bar}`, detail: `met on the input and not on the revision${quoted.length > 0 ? `: ${quoted.join(' | ')}` : ''}; a ${op} may not fail a bar its input meets` });
474
484
  }
475
485
  return out;
476
486
  }
@@ -511,24 +521,29 @@ export function modelAttachmentHash(modelText: string, part: string): string | n
511
521
  return entry === undefined ? null : createHash('sha256').update(JSON.stringify(entry)).digest('hex');
512
522
  }
513
523
 
514
- /** Step 1's tree rules: the files `rig` writes are present, and the two the splice rewrites are this writer's bytes. */
515
- export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
524
+ /**
525
+ * Step 1's tree rules: the files `rig` writes are present, and the ones the splice rewrites (`spliced`) are this
526
+ * writer's bytes. `op` names the revising command, whose codes these are (`retune` splices `rig.json` and `motion.json`).
527
+ */
528
+ export function inputTreeProblems(tree: FileTree, root: string, op: 'remesh' | 'retune' = 'remesh', spliced: readonly string[] = ['rig.json', 'mesh_report.json']): Problem[] {
529
+ const present = REVISION_CODES[op].inputPresent;
530
+ const writtenCode = REVISION_CODES[op].inputWritten;
516
531
  const out: Problem[] = [];
517
532
  for (const f of RIG_TREE_FILES) {
518
- if (!tree.has(f)) out.push({ code: 'REMESH_INPUT_PRESENT', object: `${root}/${f}`, detail: 'is absent; the input revision is the directory rig writes — rig.json, motion.json, mesh_report.json and images/' });
533
+ if (!tree.has(f)) out.push({ code: present, object: `${root}/${f}`, detail: 'is absent; the input revision is the directory rig writes — rig.json, motion.json, mesh_report.json and images/' });
519
534
  }
520
- if (![...tree.keys()].some((k) => k.startsWith('images/'))) out.push({ code: 'REMESH_INPUT_PRESENT', object: `${root}/images/`, detail: 'holds no image; the input revision is the directory rig writes' });
521
- for (const f of ['rig.json', 'mesh_report.json']) {
535
+ if (![...tree.keys()].some((k) => k.startsWith('images/'))) out.push({ code: present, object: `${root}/images/`, detail: 'holds no image; the input revision is the directory rig writes' });
536
+ for (const f of spliced) {
522
537
  const b = tree.get(f);
523
538
  if (b === undefined) continue;
524
539
  let value: unknown;
525
540
  try {
526
541
  value = JSON.parse(text(b));
527
542
  } catch (err) {
528
- out.push({ code: 'REMESH_INPUT_WRITTEN', object: `${root}/${f}`, detail: `does not parse as JSON: ${(err as Error).message}` });
543
+ out.push({ code: writtenCode, object: `${root}/${f}`, detail: `does not parse as JSON: ${(err as Error).message}` });
529
544
  continue;
530
545
  }
531
- if (rigJsonText(value) !== text(b)) out.push({ code: 'REMESH_INPUT_WRITTEN', object: `${root}/${f}`, detail: 'is not the text this package\'s writer prints for its value; the revision is spliced as text, so an input reformatted by hand cannot be carried over byte for byte' });
546
+ if (rigJsonText(value) !== text(b)) out.push({ code: writtenCode, object: `${root}/${f}`, detail: 'is not the text this package\'s writer prints for its value; the revision is spliced as text, so an input reformatted by hand cannot be carried over byte for byte' });
532
547
  }
533
548
  return out;
534
549
  }
package/src/retune.ts ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * `rig-parts retune` (issue #527): splice the constraints and the idle a config states onto an existing, gated rig,
3
+ * and write a new package revision beside the input — every other byte of the input carried over, the input never
4
+ * written. The remesh (`src/remesh.ts`) of physics and motion: a reviewer's damping, wind, strength or
5
+ * idle-track change reaches the rig without a whole rebuild, which would drop every repair the config does not
6
+ * reproduce.
7
+ *
8
+ * ## The steps, each one a refusal by name
9
+ *
10
+ * 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
11
+ * `rig` writes them; `rig.json` and `motion.json` must be the bytes this package's writer prints for what they
12
+ * parse to (`RETUNE_INPUT_WRITTEN`), since the revision is spliced as text. `--out` must not exist
13
+ * (`RETUNE_OUT_FRESH`).
14
+ * 2. **The rebuild**: the config as written is built through the rig stage — one build, every check it owns — and a
15
+ * refusal there is quoted (`RETUNE_CONFIG_REFUSED`).
16
+ * 3. **The hold** ({@link holdProblems}): the rebuild must state the input's `stops`, `bones` (names, parents,
17
+ * transforms, `inherit`), `slots`, `skeleton` and `images`, every image file, every `invariants` entry but
18
+ * `detached`, and every byte of `motion.json` but `animations.idle` (`RETUNE_REQUEST_HELD`, naming the first
19
+ * difference). A `stops` change rewires bones, slots and weights onto drawn links, so it is no retune. No
20
+ * attachment is compared: the input's meshes may be repairs the config does not reproduce, and they are carried
21
+ * over as bytes — so a `meshes` change in the config is never read into the revision (`remesh` is its producer).
22
+ * 4. **The splice** ({@link spliceRevision}): the input's `rig.json` with `constraints` and `invariants.detached`
23
+ * (derived from the constraints, rig.ts's `detachedRules`) the rebuild's, and its `motion.json` with
24
+ * `animations.idle` the rebuild's; every other file is the input's bytes.
25
+ * 5. **The preserve assertion** ({@link preservedProblems}): every file but those two byte-identical to the input's;
26
+ * `rig.json` identical outside `constraints` and `invariants.detached`, `motion.json` outside `animations.idle`
27
+ * (`RETUNE_PRESERVED`, naming the first path that moved).
28
+ * 6. **The gate and check**: the revision goes through rigc's build gate (`RETUNE_RIGC_GREEN`) and through `check`
29
+ * beside the input; every bar the input measured is measured, none the input met is failed (`RETUNE_CHECK_HELD`).
30
+ * 7. **The write**: `--out` receives the revision's files and {@link RETUNE_FILE} — spec {@link RETUNE_SPEC} — with
31
+ * no path and no clock in it, so a second identical request writes the same bytes.
32
+ *
33
+ * Pure in the sense `src/` is held to: rigc runs as a process, but the process is injected, as `src/build.ts` takes it.
34
+ */
35
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
36
+ import { dirname, join, resolve, sep } from 'node:path';
37
+ import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
38
+ import { type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
39
+ import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
40
+ import { type CheckSide, checkHeldProblems, checkSide, checkScope, type FileTree, firstDifference, type HashRow, hashRows, inputTreeProblems, perturbed, readTree, sha256 } from './remesh.ts';
41
+ import { DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText } from './rig.ts';
42
+
43
+ /** The provenance file's spec. */
44
+ export const RETUNE_SPEC = 'rig-parts-retune/1';
45
+
46
+ /** The provenance file, at the revision's root beside `rig.json`. */
47
+ export const RETUNE_FILE = 'retune.json';
48
+
49
+ /** The two files the splice rewrites; every other file of the input is carried over as bytes. */
50
+ export const RETUNE_SPLICED = ['rig.json', 'motion.json'] as const;
51
+
52
+ /** What the hold compares between the input and the rebuild (step 3), as the provenance lists it. */
53
+ export const RETUNE_HELD = ['rig.json stops', 'rig.json bones', 'rig.json slots', 'rig.json skeleton', 'rig.json images', 'rig.json invariants but detached', 'images/', 'motion.json but animations.idle'] as const;
54
+
55
+ /** The provenance's sentence for what the hold does not compare. */
56
+ export const RETUNE_NOT_COMPARED =
57
+ 'every attachment and every mesh_report.json row: carried over from the input as bytes, whether or not the config reproduces it; a meshes change in the config is never read into the revision (remesh is the producer of a mesh)';
58
+
59
+ export interface RetuneInput {
60
+ /** The input revision: the directory `rig` wrote (`rig.json`, `motion.json`, `mesh_report.json`, `images/`). */
61
+ rig: string;
62
+ /** The config whose constraints and idle the revision takes; every other field must reproduce the input. */
63
+ config: string;
64
+ /** The directory holding `parts.json` and `parts/` the input was built from. */
65
+ parts: string;
66
+ /** The new revision's directory; it must not exist. */
67
+ out: string;
68
+ idleKeys?: IdleKeys;
69
+ pageEdges?: PageEdges;
70
+ packShape?: PackShape;
71
+ requirements?: string;
72
+ }
73
+
74
+ /**
75
+ * Planted faults for the selftest (issue #527) — never set by the CLI: `perturb` changes the first uv of the named
76
+ * part's attachment in the revision after the splice, `skipPreserve` takes the preserve assertion out and `skipHold`
77
+ * the hold, so the selftest can show what each one stops and what stops the revision without it.
78
+ */
79
+ export interface RetunePlant {
80
+ perturb?: string;
81
+ skipPreserve?: boolean;
82
+ skipHold?: boolean;
83
+ }
84
+
85
+ /** One constraint the request changed: its name and type, and each field whose value moved, in the rebuild's order. */
86
+ export interface ConstraintChange {
87
+ name: string;
88
+ type: string;
89
+ fields: string[];
90
+ }
91
+
92
+ /** `retune.json` (spec {@link RETUNE_SPEC}). */
93
+ export interface RetuneProvenance {
94
+ spec: typeof RETUNE_SPEC;
95
+ request: { idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
96
+ versions: { parts: string; rigc: string };
97
+ input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
98
+ held: { compared: string[]; not_compared: string };
99
+ changed: { constraints: ConstraintChange[]; added: string[]; removed: string[]; idle: { tracks_changed: number; duration: [number, number] } };
100
+ preserved: { identical: string[]; spliced: string[] };
101
+ revision: HashRow[];
102
+ check: { input: CheckSide; revision: CheckSide; scope: string };
103
+ }
104
+
105
+ export interface RetuneResult {
106
+ provenance: RetuneProvenance;
107
+ written: string[];
108
+ }
109
+
110
+ type Json = Record<string, unknown>;
111
+
112
+ const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
113
+ const text = (b: Uint8Array): string => new TextDecoder().decode(b);
114
+ const bytesOf = (s: string): Uint8Array => new TextEncoder().encode(s);
115
+ const bytesEqual = (a: Uint8Array | undefined, b: Uint8Array | undefined): boolean => a !== undefined && b !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
116
+
117
+ /** A rig's files as the hold and the splice read them: `rig.json` and `motion.json` parsed, `images/` by file. */
118
+ export interface RigFiles {
119
+ rig: Json;
120
+ motion: Json;
121
+ images: Map<string, Uint8Array>;
122
+ }
123
+
124
+ /** {@link RigFiles} off a tree read by {@link readTree}. */
125
+ export function rigFiles(tree: FileTree): RigFiles {
126
+ const images = new Map<string, Uint8Array>();
127
+ for (const [k, b] of tree) if (k.startsWith('images/')) images.set(k, b);
128
+ return { rig: JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json, motion: JSON.parse(text(tree.get('motion.json') as Uint8Array)) as Json, images };
129
+ }
130
+
131
+ /** `invariants` without `detached`, or undefined when nothing else is in it. */
132
+ function invariantsBut(inv: unknown): Json | undefined {
133
+ if (!isRecord(inv)) return undefined;
134
+ const rest: Json = {};
135
+ for (const [k, v] of Object.entries(inv)) if (k !== 'detached') rest[k] = v;
136
+ return Object.keys(rest).length === 0 ? undefined : rest;
137
+ }
138
+
139
+ /** `rig.json` without `constraints` and `invariants.detached` — what the preserve assertion holds byte for byte. */
140
+ export function rigOutsideRetune(rig: Json): Json {
141
+ const out: Json = {};
142
+ for (const [k, v] of Object.entries(rig)) {
143
+ if (k === 'constraints') continue;
144
+ if (k === 'invariants') {
145
+ const rest = invariantsBut(v);
146
+ if (rest !== undefined) out[k] = rest;
147
+ continue;
148
+ }
149
+ out[k] = v;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** `motion.json` without `animations.idle` — what the hold and the preserve assertion hold byte for byte. */
155
+ export function motionOutsideIdle(motion: Json): Json {
156
+ const out: Json = {};
157
+ for (const [k, v] of Object.entries(motion)) {
158
+ if (k !== 'animations' || !isRecord(v)) {
159
+ out[k] = v;
160
+ continue;
161
+ }
162
+ out[k] = Object.fromEntries(Object.entries(v).filter(([name]) => name !== 'idle'));
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Step 3: what the rebuild must state as the input does. `stops` first, so a stops change is named as one rather than
169
+ * as the bones it rewires. Each difference names its first leaf; every one is collected.
170
+ */
171
+ export function holdProblems(input: RigFiles, rebuilt: RigFiles): Problem[] {
172
+ const out: Problem[] = [];
173
+ const held = (object: string, what: string, why: string): void => {
174
+ out.push({ code: 'RETUNE_REQUEST_HELD', object, detail: `the config changes it (${what}); ${why}, so nothing is written — make it a rebuild with rig --config` });
175
+ };
176
+ const top = 'a retune replaces constraints and the idle and holds every other byte of the input';
177
+ const stops = firstDifference(input.rig.stops, rebuilt.rig.stops, 'stops');
178
+ if (stops !== null) held('rig.json stops', `input vs rebuilt: ${stops}`, 'a stops change rewires bones, slots and weights onto drawn links (<link>_d), which a retune holds');
179
+ for (const key of ['bones', 'slots', 'skeleton', 'images']) {
180
+ const d = firstDifference(input.rig[key], rebuilt.rig[key], key);
181
+ if (d !== null) held(`rig.json ${key}`, `input vs rebuilt: ${d}`, key === 'bones' ? `${top}; a moved, renamed, added or re-inherited bone (a <bone>_ctl among them) moves every weight bound to it` : top);
182
+ }
183
+ const inv = firstDifference(invariantsBut(input.rig.invariants), invariantsBut(rebuilt.rig.invariants), 'invariants');
184
+ if (inv !== null) held('rig.json invariants', `input vs rebuilt: ${inv}`, `${top}; only invariants.detached derives from the constraints`);
185
+ for (const path of [...new Set([...input.images.keys(), ...rebuilt.images.keys()])].sort()) {
186
+ const a = input.images.get(path);
187
+ const b = rebuilt.images.get(path);
188
+ if (bytesEqual(a, b)) continue;
189
+ held(path, a === undefined ? 'the rebuild writes it and the input has none' : b === undefined ? 'the input has it and the rebuild writes none' : `input ${a.length} bytes ${sha256(a).slice(0, 12)}… vs rebuilt ${b.length} bytes ${sha256(b).slice(0, 12)}…`, top);
190
+ }
191
+ const mi = motionOutsideIdle(input.motion);
192
+ const mr = motionOutsideIdle(rebuilt.motion);
193
+ if (rigJsonText(mi) !== rigJsonText(mr)) {
194
+ held('motion.json', `input vs rebuilt outside animations.idle: ${firstDifference(mi, mr) ?? 'the same value, different bytes'}`, `${top}; spec, archetype, cut, easings, groups and every animation but idle (animations_from, the face deform) are held`);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Step 4: the revision — the input tree with `rig.json`'s `constraints` and `invariants.detached` and `motion.json`'s
201
+ * `animations.idle` the rebuild's, each key in the place the rig stage writes it. Every other entry is the input's
202
+ * own bytes, by reference.
203
+ */
204
+ export function spliceRevision(input: FileTree, rebuilt: RigFiles): FileTree {
205
+ const inRig = JSON.parse(text(input.get('rig.json') as Uint8Array)) as Json;
206
+ const inMotion = JSON.parse(text(input.get('motion.json') as Uint8Array)) as Json;
207
+ const detached = isRecord(rebuilt.rig.invariants) ? rebuilt.rig.invariants.detached : undefined;
208
+ const invariantsOf = (inv: unknown): Json | undefined => {
209
+ // The rig stage writes `detached` after `idleDrivesMeshes` and before `deformMayFold` and `axes` (src/rig.ts).
210
+ const rest = invariantsBut(inv) ?? {};
211
+ const out: Json = {};
212
+ if ('idleDrivesMeshes' in rest) out.idleDrivesMeshes = rest.idleDrivesMeshes;
213
+ if (detached !== undefined) out.detached = detached;
214
+ for (const [k, v] of Object.entries(rest)) if (k !== 'idleDrivesMeshes') out[k] = v;
215
+ return Object.keys(out).length === 0 ? undefined : out;
216
+ };
217
+ const rig: Json = {};
218
+ const put = (k: string, v: unknown): void => {
219
+ if (v !== undefined) rig[k] = v;
220
+ };
221
+ for (const [k, v] of Object.entries(inRig)) {
222
+ if (k === 'constraints') put(k, rebuilt.rig.constraints);
223
+ else if (k === 'invariants') put(k, invariantsOf(v));
224
+ else rig[k] = v;
225
+ if (k === 'skins' && !('constraints' in inRig)) put('constraints', rebuilt.rig.constraints);
226
+ }
227
+ if (!('invariants' in inRig)) put('invariants', invariantsOf(undefined));
228
+ const animations = inMotion.animations as Json;
229
+ const motion: Json = { ...inMotion, animations: Object.fromEntries(Object.entries(animations).map(([k, v]) => [k, k === 'idle' ? (rebuilt.motion.animations as Json).idle : v])) };
230
+ const out: FileTree = new Map(input);
231
+ out.set('rig.json', bytesOf(rigJsonText(rig)));
232
+ out.set('motion.json', bytesOf(rigJsonText(motion)));
233
+ return out;
234
+ }
235
+
236
+ /** Where two values differ by top-level key, or by attachment under `skins`, so a moved part is named as the part it is. */
237
+ function movedPaths(a: Json, b: Json): Array<{ where: string; diff: string }> {
238
+ const out: Array<{ where: string; diff: string }> = [];
239
+ for (const k of [...new Set([...Object.keys(a), ...Object.keys(b)])]) {
240
+ if (k !== 'skins' || !isRecord(a.skins) || !isRecord(b.skins)) {
241
+ const d = firstDifference(a[k], b[k], k);
242
+ if (d !== null) out.push({ where: k, diff: d });
243
+ continue;
244
+ }
245
+ const sa = a.skins;
246
+ const sb = b.skins;
247
+ for (const skin of [...new Set([...Object.keys(sa), ...Object.keys(sb)])]) {
248
+ const pa = isRecord(sa[skin]) ? (sa[skin] as Json) : {};
249
+ const pb = isRecord(sb[skin]) ? (sb[skin] as Json) : {};
250
+ for (const slot of [...new Set([...Object.keys(pa), ...Object.keys(pb)])]) {
251
+ const xa = isRecord(pa[slot]) ? (pa[slot] as Json) : {};
252
+ const xb = isRecord(pb[slot]) ? (pb[slot] as Json) : {};
253
+ for (const name of [...new Set([...Object.keys(xa), ...Object.keys(xb)])]) {
254
+ const where = `skins.${skin}.${slot}.${name}`;
255
+ const d = firstDifference(xa[name], xb[name], where);
256
+ if (d !== null) out.push({ where, diff: d });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * Step 5, the preserve assertion: every file but `rig.json` and `motion.json` byte-identical in the revision and none
266
+ * added or dropped; `rig.json` identical outside `constraints` and `invariants.detached`, `motion.json` outside
267
+ * `animations.idle`, each moved path named.
268
+ */
269
+ export function preservedProblems(input: FileTree, revision: FileTree): Problem[] {
270
+ const out: Problem[] = [];
271
+ const moved = (object: string, detail: string): void => {
272
+ out.push({ code: 'RETUNE_PRESERVED', object, detail: `${detail}; a retune carries every byte but the constraints and the idle over from the input, so nothing is written` });
273
+ };
274
+ const outside: Record<string, { strip: (v: Json) => Json; what: string }> = {
275
+ 'rig.json': { strip: rigOutsideRetune, what: 'constraints and invariants.detached' },
276
+ 'motion.json': { strip: motionOutsideIdle, what: 'animations.idle' },
277
+ };
278
+ for (const path of [...new Set([...input.keys(), ...revision.keys()])].sort()) {
279
+ const a = input.get(path);
280
+ const b = revision.get(path);
281
+ if (a === undefined || b === undefined) {
282
+ moved(path, a === undefined ? 'is in the revision and not in the input' : 'is in the input and not in the revision');
283
+ continue;
284
+ }
285
+ const o = outside[path];
286
+ if (o === undefined) {
287
+ if (!bytesEqual(a, b)) moved(path, `is not the input's bytes (input ${a.length} bytes ${sha256(a).slice(0, 12)}…, revision ${b.length} bytes ${sha256(b).slice(0, 12)}…)`);
288
+ continue;
289
+ }
290
+ const x = o.strip(JSON.parse(text(a)) as Json);
291
+ const y = o.strip(JSON.parse(text(b)) as Json);
292
+ if (rigJsonText(x) === rigJsonText(y)) continue;
293
+ const where = movedPaths(x, y);
294
+ if (where.length === 0) moved(path, `differs from the input outside ${o.what} in its bytes, though not in its value`);
295
+ for (const w of where) moved(`${path} ${w.where}`, `differs from the input outside ${o.what} — input vs revision at ${w.diff}`);
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /** What the request changed, for the provenance: constraints by name and field, and the idle's tracks and duration. */
301
+ export function changedOf(input: RigFiles, rebuilt: RigFiles): RetuneProvenance['changed'] {
302
+ const list = (r: Json): Json[] => (Array.isArray(r.constraints) ? (r.constraints as unknown[]).filter(isRecord) : []);
303
+ const before = new Map(list(input.rig).map((c) => [String(c.name), c]));
304
+ const after = list(rebuilt.rig);
305
+ const afterNames = new Set(after.map((c) => String(c.name)));
306
+ const constraints: ConstraintChange[] = [];
307
+ for (const c of after) {
308
+ const was = before.get(String(c.name));
309
+ if (was === undefined) continue;
310
+ const keys = [...new Set([...Object.keys(c), ...Object.keys(was)])];
311
+ const fields = keys.filter((k) => JSON.stringify(c[k]) !== JSON.stringify(was[k]));
312
+ if (fields.length > 0) constraints.push({ name: String(c.name), type: String(c.type), fields });
313
+ }
314
+ const idleOf = (m: Json): { duration: number; tracks: unknown[] } => (m.animations as Json).idle as { duration: number; tracks: unknown[] };
315
+ const ia = idleOf(input.motion);
316
+ const ib = idleOf(rebuilt.motion);
317
+ let tracks = 0;
318
+ for (let i = 0; i < Math.max(ia.tracks.length, ib.tracks.length); i++) if (JSON.stringify(ia.tracks[i]) !== JSON.stringify(ib.tracks[i])) tracks++;
319
+ return {
320
+ constraints,
321
+ added: after.map((c) => String(c.name)).filter((n) => !before.has(n)),
322
+ removed: [...before.keys()].filter((n) => !afterNames.has(n)),
323
+ idle: { tracks_changed: tracks, duration: [ia.duration, ib.duration] },
324
+ };
325
+ }
326
+
327
+ /**
328
+ * The stage: steps 1–7 of the module header. Writes `input.out` only when every step is green; refuses with every
329
+ * problem of the first step that found one.
330
+ */
331
+ export function retuneStage(input: RetuneInput, rigc: RigcRunner, checkBin: string, versions: { parts: string; rigc: string }, scratch: string, log: Log, plant: RetunePlant = {}): RetuneResult {
332
+ const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
333
+ const pageEdges = input.pageEdges ?? DEFAULT_PAGE_EDGES;
334
+ const packShape = input.packShape ?? DEFAULT_PACK_SHAPE;
335
+ // 1. The input and the out.
336
+ const outAt = resolve(input.out);
337
+ const rigAt = resolve(input.rig);
338
+ if (existsSync(outAt)) throw new PartsError([{ code: 'RETUNE_OUT_FRESH', object: input.out, detail: 'exists; a revision is written into a new directory, never over the input or an earlier revision' }]);
339
+ if (!existsSync(rigAt) || !statSync(rigAt).isDirectory()) throw new PartsError([{ code: 'RETUNE_INPUT_PRESENT', object: input.rig, detail: 'is not a directory; the input revision is the directory rig writes' }]);
340
+ if (outAt.startsWith(`${rigAt}${sep}`)) throw new PartsError([{ code: 'RETUNE_OUT_FRESH', object: input.out, detail: `is inside the input ${input.rig}; a revision is written beside the input, never into it` }]);
341
+ const tree = readTree(rigAt);
342
+ refuseIfAny(inputTreeProblems(tree, input.rig, 'retune', RETUNE_SPLICED));
343
+ const inFiles = rigFiles(tree);
344
+ // 2. The rebuild.
345
+ log(`rig-parts retune: ${input.rig}, ${tree.size} file(s); the rebuild is the config as written`);
346
+ const rebuiltAt = join(scratch, 'rebuild', 'rig');
347
+ try {
348
+ rigStage({ config: input.config, parts: input.parts, out: rebuiltAt, idleKeys, pageEdges, packShape }, rigc, join(scratch, 'rebuild', 'gate'), (l) => log(`[rebuild:rig] ${l}`));
349
+ } catch (err) {
350
+ if (!(err instanceof PartsError)) throw err;
351
+ throw new PartsError(err.problems.map((p) => ({ code: 'RETUNE_CONFIG_REFUSED', object: `${input.config} as written`, detail: `the rig stage refuses it, nothing is written: ${problemLine(p)}` })));
352
+ }
353
+ const rebuilt = rigFiles(readTree(rebuiltAt));
354
+ // 3. The hold.
355
+ if (plant.skipHold !== true) refuseIfAny(holdProblems(inFiles, rebuilt));
356
+ log(` held: ${RETUNE_HELD.join(', ')} — the rebuild states each as the input does; no attachment is compared`);
357
+ const changed = changedOf(inFiles, rebuilt);
358
+ // 4–5. The splice and the preserve assertion.
359
+ let revision = spliceRevision(tree, rebuilt);
360
+ if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
361
+ if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision));
362
+ const spliced: string[] = [...RETUNE_SPLICED];
363
+ const identical = [...tree.keys()].filter((k) => !spliced.includes(k));
364
+ for (const c of changed.constraints) log(` constraint ${c.name} (${c.type}): ${c.fields.join(', ')} changed`);
365
+ if (changed.added.length > 0) log(` constraints added: ${changed.added.join(', ')}`);
366
+ if (changed.removed.length > 0) log(` constraints removed: ${changed.removed.join(', ')}`);
367
+ log(` idle: ${changed.idle.tracks_changed} track(s) changed, duration ${changed.idle.duration[0]} -> ${changed.idle.duration[1]}`);
368
+ log(` preserved: ${identical.length} file(s) byte-identical to the input; rig.json identical outside constraints and invariants.detached; motion.json identical outside animations.idle`);
369
+ // 6. The gate and check.
370
+ const pngs = [...revision].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
371
+ const texts = [...revision].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
372
+ const gate = gateThroughRigc(pngs, texts, rigc, join(scratch, 'gate'), { pageEdges, packShape });
373
+ for (const g of gate) {
374
+ log(` rigc ${g.label}, the revision: exit ${g.status}`);
375
+ for (const l of g.lines) log(` ${l.trim()}`);
376
+ }
377
+ refuseIfAny(gate.filter((g) => g.status !== 0).map((g) => ({ code: 'RETUNE_RIGC_GREEN', object: `rigc ${g.label} over the revision`, detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(g)}; exit 0 is required before anything is written, and nothing was` })));
378
+ const staged = join(scratch, 'revision');
379
+ for (const [k, b] of revision) {
380
+ mkdirSync(dirname(join(staged, k)), { recursive: true });
381
+ writeFileSync(join(staged, k), b);
382
+ }
383
+ const checkOf = (rig: string, name: string): CheckReport =>
384
+ checkStage({ rig, parts: input.parts, out: join(scratch, name), pageEdges, packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, rigc, checkBin, (l) => log(`[${name}] ${l}`));
385
+ const inCheck = checkOf(rigAt, 'check-input');
386
+ const revCheck = checkOf(staged, 'check-revision');
387
+ const sides = { input: checkSide(inCheck), revision: checkSide(revCheck) };
388
+ refuseIfAny(checkHeldProblems(sides.input, sides.revision, revCheck.problems, 'retune'));
389
+ const scope = checkScope(sides.revision.physics);
390
+ log(` check: input ${sides.input.pass ? 'PASS' : `FAIL (${sides.input.not_met.join(', ')})`}, revision ${sides.revision.pass ? 'PASS' : `FAIL (${sides.revision.not_met.join(', ')})`}, the same ${sides.revision.measured.length} bar(s) measured; ${scope}`);
391
+ // 7. The write.
392
+ const provenance: RetuneProvenance = {
393
+ spec: RETUNE_SPEC,
394
+ request: { idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
395
+ versions,
396
+ input: { rig: hashRows(tree), config_sha256: sha256(new Uint8Array(readFileSync(input.config))), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
397
+ held: { compared: [...RETUNE_HELD], not_compared: RETUNE_NOT_COMPARED },
398
+ changed,
399
+ preserved: { identical, spliced },
400
+ revision: hashRows(revision),
401
+ check: { ...sides, scope },
402
+ };
403
+ const written: string[] = [];
404
+ mkdirSync(outAt, { recursive: true });
405
+ for (const [k, b] of revision) {
406
+ mkdirSync(dirname(join(outAt, k)), { recursive: true });
407
+ writeFileSync(join(outAt, k), b);
408
+ written.push(k);
409
+ }
410
+ writeFileSync(join(outAt, RETUNE_FILE), rigJsonText(provenance));
411
+ written.push(RETUNE_FILE);
412
+ log(' file sha256 (input) sha256 (revision)');
413
+ const inHash = new Map(hashRows(tree).map((r) => [r.path, r.sha256]));
414
+ for (const r of provenance.revision) log(` ${r.path.padEnd(40)} ${(inHash.get(r.path) ?? 'absent').slice(0, 16)} ${r.sha256.slice(0, 16)}${inHash.get(r.path) === r.sha256 ? '' : ' (spliced)'}`);
415
+ log(`rig-parts retune: wrote ${written.length} file(s) under ${input.out}, the provenance in ${RETUNE_FILE}`);
416
+ rmSync(staged, { recursive: true, force: true });
417
+ return { provenance, written };
418
+ }