@henols/c64-re-tools 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/THIRD-PARTY-NOTICES.md +26 -0
  3. package/bin/cli.mjs +18 -7
  4. package/package.json +6 -4
  5. package/skills/acme-build/SKILL.md +83 -33
  6. package/skills/acme-build/scripts/acme.mjs +159 -64
  7. package/skills/acme-build/template.a +1 -1
  8. package/skills/c64-disk-access/SKILL.md +156 -0
  9. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  10. package/skills/c64-memory-mapping/SKILL.md +419 -23
  11. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  12. package/skills/c64-petcat/SKILL.md +87 -0
  13. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  14. package/skills/c64-program-recon/SKILL.md +497 -92
  15. package/skills/c64-program-recon/references/control-flow.md +12 -15
  16. package/skills/c64-program-recon/references/graphics.md +1 -1
  17. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  18. package/skills/c64-program-recon/references/reconstruction.md +11 -6
  19. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  20. package/skills/c64-program-recon/references/tool-selection.md +37 -18
  21. package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
  22. package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
  23. package/skills/c64-provenance-diff/SKILL.md +43 -8
  24. package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
  25. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
  26. package/skills/c64-ram-capture/RELEASES.json.example +17 -0
  27. package/skills/c64-ram-capture/SKILL.md +147 -46
  28. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  29. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  30. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  31. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  32. package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
  33. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  34. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  35. package/skills/c64-ram-capture/scripts/watch-loads.mjs +19 -13
  36. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  37. package/skills/c64-ram-capture/transients/README.md +136 -0
  38. package/skills/routine-queue-walker/SKILL.md +365 -0
  39. package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
  40. package/skills/vice-wedge-triage/SKILL.md +104 -97
  41. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
  42. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
  43. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
  44. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
  45. package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
  46. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ // mcp-module.mjs -- the ONE resolution ladder a skill script uses to reach a
3
+ // module that lives in the OTHER package (`@henols/vice-mcp`'s `src/mcp/vice/`
4
+ // tree, as opposed to this file's own `@henols/c64-re-tools` skills tree).
5
+ //
6
+ // ---------------------------------------------------------------------------
7
+ // WHY THIS FILE EXISTS
8
+ // ---------------------------------------------------------------------------
9
+ // Before this file existed, `vsf-slice.mjs`
10
+ // carried its own copy of this exact three-rung ladder (`ladder()` /
11
+ // `resolveTarget()`), and TWO MORE skill scripts (`acme.mjs`,
12
+ // `packer-finding.mjs`) were about to migrate onto the host-tool execution
13
+ // seam and were each going to need the identical lookup to reach
14
+ // `host-tool-client.ts`. Three production consumers were about to carry
15
+ // three copies of one ladder -- so it is extracted here instead, and
16
+ // `vsf-slice.mjs` is rewritten to import it rather than keep its own.
17
+ //
18
+ // The underlying constraint this ladder exists to answer is
19
+ // `vsf-slice.mjs`'s own recorded finding: the MCP server ships as
20
+ // `@henols/vice-mcp`, whose `files[]` lists only `src/mcp/vice/` contents,
21
+ // while `src/skills/**` ships in a DIFFERENT package (`@henols/c64-re-tools`).
22
+ // A plain `import` from a skill script into the MCP tree resolves on NEITHER
23
+ // npm-installer route, so a skill script that needs an MCP-side module must
24
+ // LOCATE it on disk and invoke it as a subprocess (`process.execPath
25
+ // <resolved-path> ...argv`) -- never a static `import`.
26
+ //
27
+ // ---------------------------------------------------------------------------
28
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
29
+ // ---------------------------------------------------------------------------
30
+ // - the three-rung resolution order (`$VICE_MCP_DIR`, the in-repo relative
31
+ // path, then `require.resolve()` against the published package) for
32
+ // locating ANY named file inside `src/mcp/vice/` from a skill's own
33
+ // `scripts/` directory;
34
+ // - refusing BY NAME, listing every rung tried, when none resolves
35
+ // (`refusalMessage()`);
36
+ // - computing the in-repo hop count from `import.meta.url` rather than a
37
+ // fixed `".."` count -- `hostpath.ts` and `containerpath.ts` both record
38
+ // dropping a hard-coded four-level hop when they moved directories once
39
+ // already, and this project has ALSO shipped a stale hardcoded snapshot
40
+ // offset before (see `vsf-slice.mjs`'s own header) -- a fixed `".."`
41
+ // count is a mistake this project keeps making, not a one-off.
42
+ //
43
+ // ---------------------------------------------------------------------------
44
+ // WHAT NOT TO DO
45
+ // ---------------------------------------------------------------------------
46
+ // - Never carry a second copy of these three rungs, their ordering, or
47
+ // their refusal text anywhere else in this repository. A future
48
+ // skill-side consumer of an MCP-side module imports `resolveMcpModule`
49
+ // from HERE.
50
+ // - Never let a resolution failure become a local re-implementation of
51
+ // whatever was being resolved -- refuse by name instead. A silent
52
+ // fallback that reimplements the target module's own logic is exactly
53
+ // the divergence this ladder exists to prevent (see `vsf-slice.mjs`'s own
54
+ // header on the stale-offset incident this project has already had).
55
+ // - `process.execPath` spawned on a resolved `.ts`/`.mjs` file inside this
56
+ // project's OWN tree reaches NO external host binary -- it is the
57
+ // interpreter already running the calling script, pointed at an in-tree
58
+ // module. This is NOT what the host-tool execution seam (`host-tool.mts`
59
+ // / `host-tool-client.ts`) exists for, and must never be confused with
60
+ // it: the seam is for binaries that live on the HOST, outside any
61
+ // container; this ladder is for locating a file inside this project's own
62
+ // two npm packages.
63
+ import { existsSync } from "node:fs";
64
+ import { createRequire } from "node:module";
65
+ import { fileURLToPath } from "node:url";
66
+ import { basename, dirname, join, resolve } from "node:path";
67
+
68
+ const HERE = dirname(fileURLToPath(import.meta.url));
69
+
70
+ /** The package the MCP-side module ships in, for the npm-installer rung. */
71
+ export const TARGET_PACKAGE = "@henols/vice-mcp";
72
+
73
+ /**
74
+ * Walks UP from `startDir` until a directory literally named `name` is
75
+ * found, returning its absolute path -- or `null` if the filesystem root is
76
+ * reached first without a match. This is what lets the in-repo hop count be
77
+ * COMPUTED from `import.meta.url` instead of a fixed `".."` count: whichever
78
+ * skill's `scripts/` directory this module is imported from, walking up to
79
+ * the `skills/` ancestor and back down to `mcp/vice/` is the same operation.
80
+ */
81
+ function findAncestorDir(startDir, name) {
82
+ let dir = startDir;
83
+ for (;;) {
84
+ if (basename(dir) === name) return dir;
85
+ const parent = dirname(dir);
86
+ if (parent === dir) return null; // reached the filesystem root, no match
87
+ dir = parent;
88
+ }
89
+ }
90
+
91
+ // Computed once, at module load: HERE is .../src/skills/<skill>/scripts/, so
92
+ // walking up to the "skills" ancestor and taking ITS parent ("src") gives the
93
+ // root from which "mcp/vice" is a sibling of "skills" -- with no fixed hop
94
+ // count written down anywhere in this file.
95
+ const SKILLS_DIR = findAncestorDir(HERE, "skills");
96
+ const IN_REPO_MCP_VICE_DIR = SKILLS_DIR ? join(dirname(SKILLS_DIR), "mcp", "vice") : null;
97
+
98
+ /**
99
+ * The resolution ladder for `fileName`, in order, as `{ rung, path, note }`
100
+ * records -- returned IN FULL (never short-circuited), so a refusal message
101
+ * can name every path tried, which is the whole point of refusing rather
102
+ * than guessing:
103
+ *
104
+ * 1. `$VICE_MCP_DIR` -- an explicit override, checked first so a caller can
105
+ * always point this ladder at a known-good tree.
106
+ * 2. The in-repo relative path (see `findAncestorDir()` above). The plugin
107
+ * distribution keeps both trees in one checkout, so this is the rung
108
+ * that resolves during development and in CI.
109
+ * 3. `require.resolve()` against the published package, for the
110
+ * npm-installer route where the two packages are installed separately.
111
+ */
112
+ function ladder(fileName) {
113
+ const rungs = [];
114
+
115
+ const override = process.env.VICE_MCP_DIR;
116
+ rungs.push({
117
+ rung: "VICE_MCP_DIR",
118
+ path: override ? join(resolve(override), fileName) : null,
119
+ note: override ? null : "VICE_MCP_DIR is not set",
120
+ });
121
+
122
+ rungs.push({
123
+ rung: "in-repo relative path",
124
+ path: IN_REPO_MCP_VICE_DIR ? join(IN_REPO_MCP_VICE_DIR, fileName) : null,
125
+ note: IN_REPO_MCP_VICE_DIR ? null : `could not locate an ancestor "skills" directory from ${HERE}`,
126
+ });
127
+
128
+ let resolved = null;
129
+ let note = null;
130
+ try {
131
+ resolved = createRequire(import.meta.url).resolve(`${TARGET_PACKAGE}/${fileName}`);
132
+ } catch (err) {
133
+ note = `${TARGET_PACKAGE} is not resolvable from here (${err.code ?? err.message})`;
134
+ }
135
+ rungs.push({ rung: `${TARGET_PACKAGE} package`, path: resolved, note });
136
+
137
+ return rungs;
138
+ }
139
+
140
+ /**
141
+ * Builds the refusal text naming every rung tried plus the instruction to
142
+ * set `VICE_MCP_DIR` -- the ONE place this message is worded, so callers
143
+ * that construct their own additional context around it (e.g.
144
+ * `vsf-slice.mjs`'s own domain-specific rationale) do not restate it.
145
+ */
146
+ export function refusalMessage(fileName, rungs) {
147
+ const tried = rungs
148
+ .map((r) => ` ${r.rung}: ${r.path ?? "(no path)"}${r.note ? ` -- ${r.note}` : ""}`)
149
+ .join("\n");
150
+ return (
151
+ `could not resolve ${fileName}.\n` +
152
+ `Tried, in order:\n${tried}\n` +
153
+ `Set VICE_MCP_DIR to the directory holding ${fileName}.`
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Resolves `fileName` (e.g. `"vsf-slice.ts"`, `"host-tool-client.ts"`) against
159
+ * the three-rung ladder above, with NO per-file special-casing -- every
160
+ * caller gets the same three rungs in the same order.
161
+ *
162
+ * Returns `{ ok: true, rung, path }` for the first rung whose path exists on
163
+ * disk, or `{ ok: false, rungs, message }` when none resolves -- `rungs`
164
+ * carries the full attempt list (so a caller can build its own message from
165
+ * the raw records instead of `refusalMessage()`, if it wants extra context),
166
+ * and `message` is `refusalMessage(fileName, rungs)` already computed. Never
167
+ * throws.
168
+ */
169
+ export function resolveMcpModule(fileName) {
170
+ const rungs = ladder(fileName);
171
+ const hit = rungs.find((r) => r.path && existsSync(r.path));
172
+ if (hit) return { ok: true, rung: hit.rung, path: hit.path };
173
+ return { ok: false, rungs, message: refusalMessage(fileName, rungs) };
174
+ }
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // 1. The project root is found by walking UP for a `.git` marker. Counting
8
8
  // hops from `import.meta.url` breaks the moment the toolkit is installed
9
- // somewhere other than `.claude/skills/<skill>/scripts/`, and it breaks
9
+ // somewhere other than `src/skills/<skill>/scripts/`, and it breaks
10
10
  // silently -- paths resolve to a plausible wrong place rather than erroring.
11
11
  // 2. Every data location is overridable by environment variable, so a project
12
12
  // that does not use this repo's `recovery/` + `disks/` layout can point the
@@ -44,7 +44,7 @@ export function release(id) {
44
44
  }
45
45
 
46
46
  /**
47
- * The registry's own N-readiness documentation (01-03-PLAN.md's Task 2):
47
+ * The registry's own N-readiness documentation:
48
48
  * a top-level `schema_notes` string, sibling to `schema_version` and
49
49
  * `releases`, stating the mechanical claim that adding a release is one
50
50
  * `releases[]` entry plus one invocation of `tools/recover.mjs`. Kept as a
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Slice a flat 64K RAM image out of a VICE `.vsf` snapshot, with no
3
+ // transcription step anywhere.
4
+ //
5
+ // A WRAPPER, DELIBERATELY EMPTY OF LAYOUT KNOWLEDGE. Not one snapshot byte
6
+ // offset, module name or body length appears in this file. Every such fact
7
+ // lives in `src/mcp/vice/vsf-slice.ts`, which this script resolves and
8
+ // invokes. If you came here looking for the layout, you are in the wrong
9
+ // file, and that is the design.
10
+ //
11
+ // WHY IT INVOKES THE OTHER PACKAGE'S MODULE INSTEAD OF CARRYING A COPY.
12
+ // THE CONSTRAINT, MEASURED rather than assumed: the MCP server ships as
13
+ // `@henols/vice-mcp`, whose `files[]` lists only `src/mcp/vice/` contents,
14
+ // while `src/skills/**` ships in the other package (`@henols/c64-re-tools`).
15
+ // A plain `import` from a skill script into the MCP tree resolves on neither
16
+ // npm-installer route, and `scripts/check-npm-packages.mjs`'s transitive
17
+ // closure walk over `files[]` would fail the pack the moment a reachable
18
+ // module sat outside the listed set.
19
+ //
20
+ // That leaves two answers, and this file takes the second:
21
+ //
22
+ // (a) a self-contained skill-side script carrying its own copy of the
23
+ // layout -- the retired skill-side/MCP-side disk-image-reader pair's
24
+ // own precedent (two independent copies of the sector-chain walk,
25
+ // both deleted once the c1541 host-tool seam took over their one job).
26
+ // (b) a CLI entry point on the MCP-side module, which the skill invokes.
27
+ //
28
+ // (b), because the two cases are not alike. That retired pair's own
29
+ // duplicate was of a STABLE, PUBLISHED disk format that has not changed in
30
+ // forty years. The
31
+ // `.vsf` layout is version-sensitive: a second magic block moved the first
32
+ // module offset, the memory module's body length differs between two module
33
+ // minors that are both in the wild, and this project already had ONE stale
34
+ // copy of those numbers -- a prototype carrying a first-module offset that
35
+ // predates that second magic block, which survived only through a
36
+ // byte-by-byte rescan fallback that can lock onto a false module name inside
37
+ // 64 KB of RAM data. The real values, and their derivations, are in
38
+ // `vsf-slice.ts` where they belong. A second copy of the one
39
+ // authoritative reading of a version-sensitive format is precisely the
40
+ // divergence hazard the single-seam convention exists to remove. Recorded
41
+ // here as a decision so the next reader sees a choice rather than an
42
+ // oversight.
43
+ //
44
+ // IT SPAWNS `node` ON AN IN-TREE MODULE, AND NOTHING ELSE. `process.execPath`
45
+ // is the interpreter already running this script, and the target is a
46
+ // JavaScript/TypeScript file inside one of this project's own two packages.
47
+ // No external host binary is reached, so the host-tool execution seam is not
48
+ // involved here and must not be pre-empted -- that seam is for binaries that
49
+ // live on the host outside any container, which this is not.
50
+ //
51
+ // WHAT NOT TO DO:
52
+ // - Never add a fallback that slices the snapshot here. If no rung of the
53
+ // resolution ladder resolves, this script exits non-zero naming every
54
+ // path it tried. A local re-implementation would turn a resolution
55
+ // failure into a WRONG IMAGE, which is the one outcome the whole slicing
56
+ // route exists to avoid.
57
+ // - Never rewrite, prefix or summarise the target module's stderr. It is
58
+ // inherited straight through, so a refusal reaches you in the words of
59
+ // the code that refused.
60
+ // - Never copy a snapshot byte offset, a module body length or a module
61
+ // name into this file, not even in a comment as documentation, and not
62
+ // even into the usage text. The next person to update the layout will
63
+ // update `vsf-slice.ts` and will not know to look here. Its colocated
64
+ // test asserts the absence mechanically, so this is a red gate rather
65
+ // than a promise. (The `$D000-$DFFF` range in the usage below is a C64
66
+ // address range and a fact about the memory-read route, not a snapshot
67
+ // layout constant.)
68
+ import { spawnSync } from "node:child_process";
69
+
70
+ // The resolution ladder used to live HERE,
71
+ // as this file's own `ladder()`/`resolveTarget()` pair. It is now extracted
72
+ // to `mcp-module.mjs` (`resolveMcpModule()`/`refusalMessage()`), because two
73
+ // MORE skill scripts (`acme.mjs`, `packer-finding.mjs`) needed the identical
74
+ // lookup to reach the host-tool execution seam's `host-tool-client.ts`, and
75
+ // three copies of one ladder is exactly the divergence hazard this file's own
76
+ // header (below) already warns about for the `.vsf` layout itself. Import
77
+ // the ladder rather than re-adding a copy here.
78
+ import { resolveMcpModule, refusalMessage, TARGET_PACKAGE } from "./mcp-module.mjs";
79
+
80
+ /** The MCP-side module's file name, in one place. */
81
+ const TARGET_FILE = "vsf-slice.ts";
82
+
83
+ /** Forwards argv to the MCP-side entry point with stdio inherited, so its
84
+ * stdout and stderr reach the caller unmodified and its exit status is this
85
+ * script's exit status. */
86
+ function forward(argv) {
87
+ const resolved = resolveMcpModule(TARGET_FILE);
88
+
89
+ if (!resolved.ok) {
90
+ console.error(
91
+ `vsf-slice.mjs: ${refusalMessage(TARGET_FILE, resolved.rungs)}\n` +
92
+ `${TARGET_FILE} is where the .vsf layout lives. Refusing rather than slicing the snapshot ` +
93
+ `here: a second copy of a version-sensitive byte layout is how a wrong image gets produced ` +
94
+ `with no error.`,
95
+ );
96
+ return 1;
97
+ }
98
+
99
+ const run = spawnSync(process.execPath, [resolved.path, ...argv], { stdio: "inherit" });
100
+ if (run.error) {
101
+ console.error(`vsf-slice.mjs: could not run ${resolved.path}: ${run.error.message}`);
102
+ return 1;
103
+ }
104
+ if (run.signal) {
105
+ console.error(`vsf-slice.mjs: ${resolved.path} was killed by ${run.signal}`);
106
+ return 1;
107
+ }
108
+ return run.status ?? 1;
109
+ }
110
+
111
+ // Both verbs forward identically. The table exists so an unknown verb is
112
+ // answered here, with this script's usage, rather than by a subprocess whose
113
+ // own usage names a file the caller did not run.
114
+ //
115
+ // Prototype-less, via Object.create(null). A plain object
116
+ // literal inherits Object.prototype, so `commands["constructor"]` and
117
+ // `commands["toString"]` are truthy FUNCTIONS: an unknown verb that happens to
118
+ // be a prototype member passed the known-verb test and was then CALLED, so the
119
+ // documented usage was never printed and the failure surfaced as a confusing
120
+ // message from process.exit() about its argument type instead. Same idiom this
121
+ // file already uses for its flag bag, applied one level up.
122
+ const commands = Object.assign(Object.create(null), { slice: forward, digest: forward });
123
+
124
+ const [cmd, ...rest] = process.argv.slice(2);
125
+ if (!cmd || !commands[cmd]) {
126
+ console.error(`usage: node vsf-slice.mjs <command>
127
+
128
+ slice <snapshot.vsf> --out <image.bin> [--json] write the flat 64K image
129
+ digest <snapshot.vsf> sha256 of the sliced image, writing no file
130
+
131
+ Produces a flat 64K image by slicing the snapshot's memory module body. There
132
+ is no transcription step on this route, so the \`$D000-$DFFF\` volatility rule
133
+ that governs the memory-read route does NOT apply to an image produced this
134
+ way: the snapshot array is RAM under I/O, not the register read view.
135
+
136
+ The layout lives in ${TARGET_FILE} on the MCP side; this script resolves it via
137
+ VICE_MCP_DIR, the in-repo path, or ${TARGET_PACKAGE}, and refuses by name when
138
+ no rung resolves. A malformed snapshot is refused, never truncated.`);
139
+ process.exit(cmd ? 1 : 0);
140
+ }
141
+
142
+ try {
143
+ process.exit(commands[cmd]([cmd, ...rest]));
144
+ } catch (e) {
145
+ console.error(`error: ${e.message}`);
146
+ process.exit(1);
147
+ }
@@ -100,7 +100,7 @@ function loadManifestForRelease(rel) {
100
100
  * Resolve the two-tier sentinel set for one release from registry data and
101
101
  * that release's run1 range manifest -- never hardcoded. `stopping` tier is
102
102
  * one sentinel per `loader_ranges` entry (the loader-reentry sentinels that
103
- * must never fire again after the dump point per D-10). `counting` tier is
103
+ * must never fire again after the dump point). `counting` tier is
104
104
  * one sentinel per never-populated range in the run1 manifest, plus one
105
105
  * register sentinel on CIA2 port A ($DD00), which carries both the VIC
106
106
  * bank-select bits and the bit-banged serial-bus lines a KERNAL-bypassing
@@ -136,7 +136,7 @@ export function WATCH_SET(releaseId, { registry, manifest } = {}) {
136
136
  type: "exec",
137
137
  start,
138
138
  end,
139
- reason: lr.note ?? "loader-reentry range: must never fire again after the dump point (D-10)",
139
+ reason: lr.note ?? "loader-reentry range: must never fire again after the dump point",
140
140
  evidence: lr.evidence ?? "",
141
141
  });
142
142
  }
@@ -285,7 +285,7 @@ export function classifyHit(hit) {
285
285
  * are human-audit artifacts and are never hashed here or anywhere else in
286
286
  * this project: an encoder can emit different bytes for pixel-identical
287
287
  * images, and this project deliberately installs no image-decoding library
288
- * (D-18) to decode-then-hash instead.
288
+ * to decode-then-hash instead.
289
289
  */
290
290
  export function screenSignature(screenMatrixHex, spriteEnable) {
291
291
  const buf = Buffer.from(screenMatrixHex, "hex");
@@ -403,24 +403,24 @@ function renderReleaseSection(id, log) {
403
403
  for (const h of loadCandidates) {
404
404
  s += `- Hit at ${h.address} (cycle ${h.cycle}): supplementary dump \`${h.supplementary_dump ?? "unrecorded"}\`, ` +
405
405
  `registry ref \`${h.load_event_ref ?? "unrecorded"}\`. Reproducibility bar: a single capture, decided in ` +
406
- `this plan because the claim is about an observed moment rather than a stable state -- if D-13 resolves to ` +
407
- `absorbing loaded content into the canonical image, this region must be re-captured at the primary dumps' ` +
408
- `three-run bar before Phase 4 treats it as a round-trip diff target.\n`;
406
+ `the claim is about an observed moment rather than a stable state -- if loaded content is later ` +
407
+ `absorbed into the canonical image, this region must be re-captured at the primary dumps' ` +
408
+ `three-run bar before it can be treated as a round-trip diff target.\n`;
409
409
  }
410
410
  s += "\n";
411
411
  }
412
412
 
413
- s += `### Hand-off to plan 02-02\n\n`;
414
- s += "The registry's `watch_set` entries for this release are the re-armable specification: plan 02-02's own " +
415
- "executing agent re-arms the same set by issuing the same `mcp__plugin_c64-re-tools_vice__vice_checkpoint_add` calls during Phase " +
416
- "2's exhaustive all-chambers trace, and interprets what it observes with this module's pure `attributeAddress`, " +
417
- "`reportHits` and `classifyHit` functions. This is a hand-off of data and procedure, not an executable -- plan " +
418
- "02-02's own plan text should describe agent-performed arming with acceptance criteria over a committed record " +
413
+ s += `### Hand-off to the exhaustive trace\n\n`;
414
+ s += "The registry's `watch_set` entries for this release are the re-armable specification: whoever runs the " +
415
+ "exhaustive all-chambers trace re-arms the same set by issuing the same `mcp__plugin_c64-re-tools_vice__vice_checkpoint_add` " +
416
+ "calls, and interprets what it observes with this module's pure `attributeAddress`, " +
417
+ "`reportHits` and `classifyHit` functions. This is a hand-off of data and procedure, not an executable -- describe " +
418
+ "agent-performed arming with acceptance criteria over a committed record " +
419
419
  "rather than over an exit code. A late hit there reopens this document.\n\n";
420
420
 
421
421
  s += `### Input sequence notes\n\n`;
422
422
  s += (log.input_notes ?? "(no input notes recorded)") + "\n\n";
423
- s += "Per D-12 this is plain notes, not a `verify/scripts/` artifact -- VERIFY-01 in Phase 3 owns the real " +
423
+ s += "This is plain notes, not a `verify/scripts/` artifact -- the replay tooling owns the real " +
424
424
  "input-script format; these notes are a seed for it, not a pre-empting specification.\n\n";
425
425
 
426
426
  s += `### Teardown proof\n\n`;
@@ -445,6 +445,12 @@ function renderReleaseSection(id, log) {
445
445
  * string templating over already-fetched data -- nothing here reads a file
446
446
  * or contacts anything; the CLI `render` verb below is what reads the
447
447
  * hit-log files and writes the result.
448
+ *
449
+ * The two script paths named in the output below are deliberately the
450
+ * consumer's installed location, not this repository's source tree -- this
451
+ * string is written into a `recovery/LOADING.md` a consumer keeps. A blanket
452
+ * path sweep rewrote them to the source tree once already; pinned
453
+ * by the test below plus the class-level registry in skill-consumer-paths.test.ts.
448
454
  */
449
455
  export function renderLoading(entries) {
450
456
  let out = "# `recovery/LOADING.md` -- the on-demand-load detection record\n\n";
@@ -4,6 +4,13 @@ One record per capture. Every field is recorded **in the same step as the
4
4
  capture**, before the machine is resumed — a value read later describes a
5
5
  different machine.
6
6
 
7
+ Every `node scripts/…` command cited below is relative to **this skill's own
8
+ directory** (`src/skills/c64-ram-capture/`), not to the repository root. From
9
+ the repo root, use the `$S`-derived variables `SKILL.md`'s quick-reference
10
+ block defines — `node $V slice …` rather than `node scripts/vsf-slice.mjs
11
+ slice …`. Stated once here because the shorthand is used in five rows and
12
+ `scripts/` resolves from neither cwd by accident.
13
+
7
14
  ## Identity
8
15
 
9
16
  | Field | Value | How obtained |
@@ -11,10 +18,30 @@ different machine.
11
18
  | image path | `recovery/<release>/dumps/<name>.bin` | — |
12
19
  | size | `65536` bytes | must be exact; anything else is not a full image |
13
20
  | sha256 | `<64 hex chars>` | `node scripts/compare.mjs digest <name>.bin` |
21
+ | binary sha256 | `<64 hex chars>` | `sha256sum <release>.d64` (or `.prg`), or `node scripts/compare.mjs digest <release>.d64`. This identifies the release by its **bytes**, never by its filename |
22
+ | argv digest | `<64 hex chars>` | `argvDigest()` in `src/mcp/vice/capture-predicate.ts` — sha256 over the **exact spawn argv array joined by a single NUL byte**. **Order-sensitive by construction**, which is the whole reason it is a field of its own. It refuses an empty argv array rather than digesting the empty string, so a blank here is a blank and never a plausible-looking digest |
23
+ | seed | `4242` | the `-seed` value the instance was launched with — `STOCK_DETERMINISM_SEED` in `src/mcp/vice/broker-launch.mts`. Cite the constant, not a loose number |
24
+ | capture route | `memory-read` \| `snapshot` | `memory-read` = the sixteen `vice_memory_read` calls, transcribed. `snapshot` = `node scripts/vsf-slice.mjs slice <run>.vsf --out <run>.bin`. **This row decides which volatility rule applies to this record** — see the closing note |
14
25
  | checkpoint / trigger address | `$____` | the address armed for this capture |
15
26
  | release | the registry id this capture belongs to | — |
16
27
  | run | `<N>` of `<total>` | three runs is this project's minimum for a verified capture |
17
28
 
29
+ ### The reproducibility key is the triple, not the seed
30
+
31
+ **`(binary sha256, argv digest, seed)`.** All three rows, together. Two captures
32
+ share a reproducibility key only when all three fields are exactly equal.
33
+
34
+ **MEASURED: the same seed with a reordered argv yielded a 76-byte-different
35
+ image.** So the seed alone is not the key, and a record keyed on it claims a
36
+ reproducibility it does not have. Two captures whose argv digests differ are
37
+ **different keys and must not be compared as a pair**, even at the same seed —
38
+ including when the difference is a single reordered element, because the digest
39
+ is order-sensitive and a reordered argv is a different launch.
40
+
41
+ **A record with any of the three fields blank is not a reproducible capture.**
42
+ The void protocol below applies to it: it is not a capture with a gap in its
43
+ paperwork, it is a capture whose identity cannot be established.
44
+
18
45
  ## Machine state at the capture instant
19
46
 
20
47
  Read these *before* resuming, in the same paused window as the memory reads.
@@ -30,6 +57,8 @@ Read these *before* resuming, in the same paused window as the memory reads.
30
57
  ## Verdict
31
58
 
32
59
  - [ ] Size is exactly 65536 bytes.
60
+ - [ ] All three reproducibility-key fields — `binary sha256`, `argv digest`, `seed` — are filled. A blank in any one of them fails this box.
61
+ - [ ] The `capture route` row is filled, so a reader knows which volatility rule this record is under.
33
62
  - [ ] No epoch-drift error appeared at any point during the capture.
34
63
  - [ ] `vice_checkpoint_list` reported zero checkpoints before resuming.
35
64
  - [ ] Machine resumed exactly once, at the end.
@@ -53,7 +82,18 @@ and keep the voided artifacts on disk.
53
82
  Record the drift floor address count, and state it as a floor rather than a
54
83
  complete set — more captures of the same checkpoint can only widen it.
55
84
 
56
- **Any divergence inside `$D000-$DFFF` is not a divergence.** That range is
57
- register images, not RAM: reading it samples live hardware, so it can never be
58
- stable across two captures. Classify it as volatile and say so in the record
59
- rather than voiding a good capture over it.
85
+ ### `$D000-$DFFF`: the rule depends on the `capture route` row
86
+
87
+ This exclusion is a property of **how the image was transcribed**, not of the
88
+ C64. Read the `capture route` row and apply the matching half. Getting this
89
+ backwards costs a good capture in one direction and hides 4096 addresses of real
90
+ divergence in the other.
91
+
92
+ | `capture route` | Rule for `$D000-$DFFF` |
93
+ |---|---|
94
+ | `memory-read` | **Any divergence inside `$D000-$DFFF` is not a divergence.** On this route the range is read through `vice_memory_read`, which samples live I/O: the VIC's registers repeat every `$40` across `$D000-$D3FF` and the SID's across `$D400-$D7FF`, so two reads can never agree there. Classify it as volatile, say so in the record, and do not void a good capture over it. |
95
+ | `snapshot` | **The exclusion does not apply, and a divergence there is a real divergence.** A `.vsf`-sliced image is the `C64MEM` array — `mem_ram[]`, which is RAM *under* I/O and not the register read view — so those 4096 addresses are ordinary RAM in a sliced image. There is no transcription step on this route to sample anything. Do not carry the memory-read exclusion across. |
96
+
97
+ Both halves are live: records taken on the memory-read route still exist and
98
+ still need the first row. What is not permitted is applying either half without
99
+ reading which route produced the image.
@@ -0,0 +1,136 @@
1
+ # Per-release transient allow-lists
2
+
3
+ One committed JSON artifact per release, written by
4
+ `../scripts/derive-transients.mjs derive`. Nothing in this directory is
5
+ inherited, hand-edited or carried across releases.
6
+
7
+ **What carries forward is the method below and the script that implements it.
8
+ Never an address set.** A real release's transients are its own frame counters,
9
+ RNG state, sprite positions and music-player pointers. An allow-list borrowed
10
+ from another release cannot be distinguished afterwards from one honestly
11
+ derived, so a contaminated ledger has no cheap repair — it has to be re-derived
12
+ from fresh captures.
13
+
14
+ ## The committed derivation method
15
+
16
+ Verbatim, because this is the part that is meant to survive:
17
+
18
+ - **N >= 3 runs** of the **same release** under the **same protocol** at the
19
+ **same stop**. Three runs is already this project's documented minimum for a
20
+ verified capture. Fewer is refused, naming the count and the minimum.
21
+ - The allow-list is the **union of addresses differing across the pairwise
22
+ comparisons** — every pairing, which is N(N-1)/2 for N images, not just the
23
+ adjacent ones.
24
+ - Each entry records **the address**, **which run pairs it differed in**, the
25
+ distinct byte values seen, and **a one-line attribution where known** (left
26
+ empty otherwise, so an unattributed transient is visibly unattributed rather
27
+ than absent).
28
+ - It is **re-derived per release**, and **no address set is ever inherited
29
+ between releases**. Re-deriving over an existing artifact is refused without
30
+ an explicit `--force`.
31
+ - The list is **enumerated** — one entry per address, never a range, a span, a
32
+ page or a region. `parseAllowList()` in
33
+ `src/mcp/vice/capture-predicate.ts` refuses range-shaped keys by name.
34
+ - There is **no bit-count tolerance** at any address. A one-bit difference
35
+ outside the list fails.
36
+
37
+ ```bash
38
+ S=src/skills/c64-ram-capture/scripts # from the repo root
39
+ T=src/skills/c64-ram-capture/transients
40
+
41
+ node $S/derive-transients.mjs derive --release <id> \
42
+ --out $T/<id>.json run1.bin run2.bin run3.bin
43
+
44
+ node $S/derive-transients.mjs check --allow-list $T/<id>.json runA.bin runB.bin
45
+ ```
46
+
47
+ ## The artifact shape
48
+
49
+ ```json
50
+ {
51
+ "schema_version": "1.0",
52
+ "release": "<the registry id the runs came from>",
53
+ "derived_from": ["run1.bin", "run2.bin", "run3.bin"],
54
+ "pair_count": 3,
55
+ "cap": 64,
56
+ "method": "union of addresses differing across every pairwise comparison ...",
57
+ "entries": [
58
+ {
59
+ "address": 164,
60
+ "pairs": ["run1.bin vs run2.bin", "run2.bin vs run3.bin"],
61
+ "values": ["$11", "$22"],
62
+ "attribution": ""
63
+ }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ `entries` is ascending by address, so the artifact is diffable and an entry's
69
+ position never depends on which pairing happened to observe it first. `address`
70
+ is an integer; `values` are the distinct bytes seen at it, in hex, across every
71
+ pairing it differed in. This is exactly the shape `parseAllowList()` accepts —
72
+ there is no translation step between the derivation and the predicate, and the
73
+ derivation's colocated test asserts the round-trip rather than assuming it.
74
+
75
+ ## The cap is 64, and exceeding it VOIDS the derivation
76
+
77
+ `TRANSIENT_ALLOW_LIST_CAP = 64` in `src/mcp/vice/capture-predicate.ts` is the
78
+ one definition; the script's default cap is asserted equal to it by test.
79
+ `--cap` **only ever narrows** — a value above the committed cap is refused by
80
+ name.
81
+
82
+ **Over the cap is not "the list is a bit long".** It means **the stop is not
83
+ frame-exact**, and that is a fact the gate must hear rather than a threshold to
84
+ move. So the script exits non-zero, writes **no artifact at all** — not a
85
+ partial one and not a truncated one — and says so. A list truncated to fit the
86
+ cap would make every later comparison pass on bytes nobody vetted, which is the
87
+ one failure this rule exists to remove. Raising the cap after seeing a
88
+ derivation overflow converts a measurement into an excuse.
89
+
90
+ ### What the cap actually distinguishes — four measured reference points
91
+
92
+ The cap does not separate "a few transients" from "a lot of transients". It
93
+ separates **a frame-exact stop** from **a stop that is not**. The numbers, all
94
+ measured on this host:
95
+
96
+ | Differing addresses | The stop that produced it | Verdict under the cap |
97
+ |---|---|---|
98
+ | **0** | frame-exact stop at the KERNAL `READY` prompt | derivation writes |
99
+ | **66** | autostarted stop, frame anchor, pre-protocol jitter 4000 ms (48 for the reported jitter-0/2500 pair) | **VOID** — 2 over |
100
+ | **300** | wall-clock autostarted stop on a real cracked release | **VOID** — 4.7× the cap |
101
+ | **1242** | wall-clock `READY`-prompt stop with the determinism block fully applied | **VOID** — 19× the cap |
102
+
103
+ Read the second row carefully: **overflow is an observed outcome, not a
104
+ hypothetical.** A frame-anchored autostarted stop has already been measured
105
+ over the cap at one jitter and under it at another. The answer to that is a
106
+ better stop, recorded with the jitter it was taken at — not a bigger cap.
107
+
108
+ The 1242 row is also the reason the seed is not the whole story: that stop had
109
+ the determinism block applied and still differed at 1242 bytes, because it was
110
+ wall-clock-anchored. The residual is what the reset protocol and the frame
111
+ anchor close, not what the seed does.
112
+
113
+ ## `$0000` / `$0001` do not belong in a derived list by hand
114
+
115
+ The 6510 port overlay is normalised **in code**, once, by `normalisePorts()` on
116
+ the snapshot route. Spending two of the cap's 64 slots on those two addresses
117
+ would hide a real difference behind a known one. They can legitimately appear in
118
+ a derivation taken from un-normalised images — `derive` compares the bytes it is
119
+ given — so if you see them in an artifact, that is what it is telling you about
120
+ the images, not a rule to add.
121
+
122
+ ## What this directory refuses, and why now
123
+
124
+ `.gitignore` here refuses every image byte form — flat captures, disk and tape
125
+ images, cartridges, `.vsf` snapshots, and the archives an image arrives inside —
126
+ and it was committed **before the first derivation existed**. A directory that
127
+ starts refusing images after the first capture lands has already had one
128
+ commit's worth of opportunity to leak one. JSON is deliberately not refused:
129
+ the derived allow-lists are the artifacts this directory is for.
130
+
131
+ **If you are reading this from an installed copy of the skill, that `.gitignore`
132
+ did not come with it.** npm excludes a nested `.gitignore` from a published
133
+ tarball, so the installed `transients/` directory has this README and nothing
134
+ else. Add the same refusals to your own project's ignore rules before you take
135
+ the first capture — the whole point of the timing is that it happens before,
136
+ not after.