@henols/c64-re-tools 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +61 -0
  2. package/bin/cli.mjs +226 -0
  3. package/package.json +53 -0
  4. package/skills/acme-build/SKILL.md +224 -0
  5. package/skills/acme-build/scripts/acme.mjs +263 -0
  6. package/skills/acme-build/template.a +39 -0
  7. package/skills/c64-memory-mapping/SKILL.md +199 -0
  8. package/skills/c64-memory-mapping/memmap.json +8800 -0
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
  10. package/skills/c64-program-recon/SKILL.md +172 -0
  11. package/skills/c64-program-recon/references/control-flow.md +174 -0
  12. package/skills/c64-program-recon/references/graphics.md +73 -0
  13. package/skills/c64-program-recon/references/observation-hazards.md +118 -0
  14. package/skills/c64-program-recon/references/reconstruction.md +128 -0
  15. package/skills/c64-program-recon/references/sound-and-input.md +68 -0
  16. package/skills/c64-program-recon/references/tool-selection.md +55 -0
  17. package/skills/c64-program-recon/scripts/derive.mjs +364 -0
  18. package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
  19. package/skills/c64-provenance-diff/SKILL.md +257 -0
  20. package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
  21. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
  23. package/skills/c64-ram-capture/SKILL.md +306 -0
  24. package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
  25. package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
  26. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
  28. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
  29. package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
  30. package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
  31. package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
  32. package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
  33. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
  34. package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
  35. package/skills/vice-wedge-triage/SKILL.md +149 -0
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+ // The N-way release-registry invariant validator, and the release-name
3
+ // parameterisation gate. Neither touches the emulator; both are pure
4
+ // Node/filesystem checks over `recovery/RELEASES.json` and the files it
5
+ // references, run entirely offline.
6
+ //
7
+ // This is the mechanical enforcement of 01-01-PLAN.md's assumption_delta
8
+ // decision: the registry is release-CENTRIC (N releases, each a full field
9
+ // set, `canonical` demoted to a boolean on one entry), never
10
+ // canonical-image-centric again. A future plan that quietly reintroduces a
11
+ // privileged singular image, or hardcodes a release name into control flow,
12
+ // fails loudly here instead of silently regressing the model.
13
+ //
14
+ // Every failure names the release, the file, and the field -- never a bare
15
+ // "validation failed" -- per this plan's own instruction.
16
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { fileURLToPath } from "node:url";
19
+ import { dirname, join, resolve, relative, extname } from "node:path";
20
+
21
+ import { loadRegistry, registryPath } from "../../c64-ram-capture/scripts/releases.mjs";
22
+ import { projectRoot, dataRoot, disksRoot } from "../../c64-ram-capture/scripts/project-paths.mjs";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const REPO_ROOT = projectRoot();
26
+ const RECOVERY_DIR = dataRoot();
27
+ const DISKS_DIR = disksRoot();
28
+ // The parameterisation gate must cover EVERY module of the recovery pipeline, not
29
+ // just the ones sitting next to this file. When the six modules moved out of
30
+ // `tools/` into the two skills that use them (2026-08-04), a `HERE`-only scan
31
+ // silently stopped covering `d64-parse.mjs` and `dump-artifacts.mjs` -- a static
32
+ // guard that keeps passing while checking less is worse than one that fails.
33
+ const SCAN_DIRS = [
34
+ HERE, // .claude/skills/c64-provenance-diff/scripts
35
+ resolve(REPO_ROOT, ".claude", "skills", "c64-ram-capture", "scripts"),
36
+ ];
37
+
38
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
39
+
40
+ function sha256File(path) {
41
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
42
+ }
43
+
44
+ function rel(p) {
45
+ return relative(REPO_ROOT, p);
46
+ }
47
+
48
+ // ---------------------------------------------------------------- validate
49
+
50
+ const REQUIRED_DUMP_FILE_FIELDS = ["bin", "capture_record", "chip_state", "range_manifest"];
51
+
52
+ // Directories under recovery/ that are NOT per-release directories and are
53
+ // never expected to have a matching releases[] entry: `clean/` is the
54
+ // canonical-image projection (checked separately, below), and `machine/`
55
+ // holds machine-level (not release-level) evidence -- the power-on baseline
56
+ // and decay-prone address set captured once per emulator, with no release
57
+ // identity of its own (see tools/recover.mjs's `baseline`/`decay-reference`
58
+ // verbs).
59
+ const NON_RELEASE_DIRS = ["clean", "machine"];
60
+
61
+ /**
62
+ * Base invariants, checked on every `validate` run regardless of `--final`.
63
+ * Returns a flat list of addressed error strings (each names the release,
64
+ * file, and/or field involved) -- never a bare boolean.
65
+ */
66
+ function runBaseChecks(registry) {
67
+ const errors = [];
68
+ const releases = registry.releases;
69
+
70
+ // -- directory <-> registry entry correspondence: no orphan on either side --
71
+ const dirEntries = readdirSync(RECOVERY_DIR, { withFileTypes: true })
72
+ .filter((d) => d.isDirectory() && !NON_RELEASE_DIRS.includes(d.name))
73
+ .map((d) => d.name);
74
+ const registryIds = releases.map((r) => r.id);
75
+
76
+ for (const dirName of dirEntries) {
77
+ if (!registryIds.includes(dirName)) {
78
+ errors.push(`orphan directory recovery/${dirName}/ has no matching releases[] entry (known ids: ${registryIds.join(", ")})`);
79
+ }
80
+ }
81
+ for (const id of registryIds) {
82
+ if (!dirEntries.includes(id)) {
83
+ errors.push(`releases[] entry "${id}" has no matching recovery/${id}/ directory`);
84
+ }
85
+ }
86
+
87
+ // -- every release conforms to the same field set --
88
+ if (releases.length > 0) {
89
+ const canonicalFieldSet = Object.keys(releases[0]).sort().join(",");
90
+ for (const r of releases) {
91
+ const fieldSet = Object.keys(r).sort().join(",");
92
+ if (fieldSet !== canonicalFieldSet) {
93
+ errors.push(
94
+ `release "${r.id}" has a different field set than release "${releases[0].id}" -- ` +
95
+ `got [${Object.keys(r).sort().join(", ")}], expected [${Object.keys(releases[0]).sort().join(", ")}]`
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ // -- at most one canonical:true, and the designation is a field, never a --
102
+ // -- directory name or filename --
103
+ const canonicalReleases = releases.filter((r) => r.canonical === true);
104
+ if (canonicalReleases.length > 1) {
105
+ errors.push(
106
+ `more than one release carries canonical:true -- ${canonicalReleases.map((r) => r.id).join(", ")}. ` +
107
+ `At most one release may be canonical.`
108
+ );
109
+ }
110
+
111
+ // -- each release's disk_sha256 still matches the file under disks/ --
112
+ for (const r of releases) {
113
+ const diskPath = join(REPO_ROOT, r.disk_image);
114
+ if (!existsSync(diskPath)) {
115
+ errors.push(`release "${r.id}": disk_image "${r.disk_image}" does not exist on disk`);
116
+ continue;
117
+ }
118
+ const actual = sha256File(diskPath);
119
+ if (actual !== r.disk_sha256) {
120
+ errors.push(
121
+ `release "${r.id}": disk_sha256 field "${r.disk_sha256}" does not match the actual sha256 of ` +
122
+ `${r.disk_image} ("${actual}") -- the evidence file may have been mutated`
123
+ );
124
+ }
125
+ }
126
+
127
+ // -- every dumps[] entry names four existing files; the bin's sha256 --
128
+ // -- matches the file on disk --
129
+ for (const r of releases) {
130
+ for (const d of r.dumps ?? []) {
131
+ for (const field of REQUIRED_DUMP_FILE_FIELDS) {
132
+ const value = d[field];
133
+ if (!value) {
134
+ errors.push(`release "${r.id}" dump "${d.label}": field "${field}" is not set (a dump is a four-file set, per D-04/D-02)`);
135
+ continue;
136
+ }
137
+ const filePath = join(REPO_ROOT, value);
138
+ if (!existsSync(filePath)) {
139
+ errors.push(`release "${r.id}" dump "${d.label}": field "${field}" names "${value}", which does not exist`);
140
+ }
141
+ }
142
+ if (d.bin && existsSync(join(REPO_ROOT, d.bin))) {
143
+ const actual = sha256File(join(REPO_ROOT, d.bin));
144
+ if (d.sha256 && actual !== d.sha256) {
145
+ errors.push(
146
+ `release "${r.id}" dump "${d.label}": recorded sha256 "${d.sha256}" does not match the actual ` +
147
+ `sha256 of ${d.bin} ("${actual}")`
148
+ );
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // -- `<data root>/clean/*.bin`, when present, is a PROJECTION: exactly one --
155
+ // -- canonical release, and the file byte-identical to that release's --
156
+ // -- primary dump. --
157
+ //
158
+ // The clean copy is DISCOVERED rather than named, so this works whatever a
159
+ // project calls its projection. Naming one specific file meant the check
160
+ // silently passed for every project that named it anything else -- a guard
161
+ // that quietly stops guarding is worse than one that fails.
162
+ const cleanDir = join(RECOVERY_DIR, "clean");
163
+ const cleanBins = existsSync(cleanDir)
164
+ ? readdirSync(cleanDir).filter((f) => f.toLowerCase().endsWith(".bin")).sort()
165
+ : [];
166
+
167
+ if (cleanBins.length > 1) {
168
+ errors.push(
169
+ `${rel(cleanDir)} holds ${cleanBins.length} .bin files (${cleanBins.join(", ")}) -- ` +
170
+ `the clean projection must be a single file, otherwise there is no way to tell which one ` +
171
+ `is the projection of the canonical release`
172
+ );
173
+ } else if (cleanBins.length === 1) {
174
+ const cleanBinPath = join(cleanDir, cleanBins[0]);
175
+ const cleanName = rel(cleanBinPath);
176
+ if (canonicalReleases.length !== 1) {
177
+ errors.push(
178
+ `${cleanName} exists, but ${canonicalReleases.length} releases carry canonical:true ` +
179
+ `(expected exactly 1) -- the projection has no single source to be a projection OF`
180
+ );
181
+ } else {
182
+ const canonicalRelease = canonicalReleases[0];
183
+ const primaryDump = (canonicalRelease.dumps ?? [])[0];
184
+ if (!primaryDump || !primaryDump.bin || !existsSync(join(REPO_ROOT, primaryDump.bin))) {
185
+ errors.push(
186
+ `${cleanName} exists, but canonical release "${canonicalRelease.id}" has no ` +
187
+ `primary dump with an existing .bin to compare against`
188
+ );
189
+ } else {
190
+ const cleanHash = sha256File(cleanBinPath);
191
+ const primaryHash = sha256File(join(REPO_ROOT, primaryDump.bin));
192
+ if (cleanHash !== primaryHash) {
193
+ errors.push(
194
+ `${cleanName} (sha256 ${cleanHash}) is NOT byte-identical to canonical release ` +
195
+ `"${canonicalRelease.id}"'s primary dump ${primaryDump.bin} (sha256 ${primaryHash}) -- ` +
196
+ `the clean copy must be an exact projection, never an independent artifact`
197
+ );
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ return errors;
204
+ }
205
+
206
+ /** End-of-phase-only assertions, added by `validate --final`. */
207
+ function runFinalChecks(registry) {
208
+ const errors = [];
209
+ const releases = registry.releases;
210
+
211
+ for (const r of releases) {
212
+ for (const d of r.dumps ?? []) {
213
+ if (!d.range_manifest) continue; // already reported by runBaseChecks
214
+ const manifestPath = join(REPO_ROOT, d.range_manifest);
215
+ if (!existsSync(manifestPath)) continue; // already reported by runBaseChecks
216
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
217
+ if (manifest.classification_state !== "bucketed") {
218
+ errors.push(
219
+ `release "${r.id}" dump "${d.label}": range_manifest ${d.range_manifest} has classification_state ` +
220
+ `"${manifest.classification_state}", expected "bucketed" at the end of the phase`
221
+ );
222
+ }
223
+ const unclassified = (manifest.ranges ?? []).filter((rg) => rg.kind === "unclassified");
224
+ if (unclassified.length > 0) {
225
+ errors.push(
226
+ `release "${r.id}" dump "${d.label}": range_manifest ${d.range_manifest} still has ` +
227
+ `${unclassified.length} range(s) with the transient kind "unclassified" -- must be fully bucketed`
228
+ );
229
+ }
230
+ }
231
+
232
+ if (!r.trigger || r.trigger.address == null) {
233
+ errors.push(`release "${r.id}": trigger.address is null -- every release needs a non-null recorded trigger by the end of the phase`);
234
+ }
235
+ }
236
+
237
+ const canonicalReleases = releases.filter((r) => r.canonical === true);
238
+ if (canonicalReleases.length !== 1) {
239
+ errors.push(
240
+ `exactly one release must carry canonical:true at the end of the phase -- found ${canonicalReleases.length} ` +
241
+ `(${canonicalReleases.map((r) => r.id).join(", ") || "none"})`
242
+ );
243
+ }
244
+
245
+ return errors;
246
+ }
247
+
248
+ export function validateRegistry({ final = false } = {}) {
249
+ const registry = loadRegistry();
250
+ const errors = [...runBaseChecks(registry)];
251
+ if (final) errors.push(...runFinalChecks(registry));
252
+ return { ok: errors.length === 0, final, errors };
253
+ }
254
+
255
+ /** Validate a single release directory's shape against a loaded registry entry. */
256
+ export function validateReleaseDir(id) {
257
+ const registry = loadRegistry();
258
+ const r = registry.releases.find((x) => x.id === id);
259
+ if (!r) return { ok: false, errors: [`no releases[] entry for "${id}"`] };
260
+ const dirPath = join(RECOVERY_DIR, id);
261
+ if (!existsSync(dirPath)) return { ok: false, errors: [`recovery/${id}/ does not exist`] };
262
+ const errors = [];
263
+ for (const d of r.dumps ?? []) {
264
+ for (const field of REQUIRED_DUMP_FILE_FIELDS) {
265
+ if (!d[field]) errors.push(`release "${id}" dump "${d.label}": field "${field}" is not set`);
266
+ }
267
+ }
268
+ return { ok: errors.length === 0, errors };
269
+ }
270
+
271
+ // ------------------------------------------------------- check-parameterisation
272
+
273
+ function listMjsFiles(dir) {
274
+ const out = [];
275
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
276
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
277
+ const full = join(dir, entry.name);
278
+ if (entry.isDirectory()) out.push(...listMjsFiles(full));
279
+ else if (entry.isFile() && extname(entry.name) === ".mjs") out.push(full);
280
+ }
281
+ return out;
282
+ }
283
+
284
+ // Matches a registry release id used as the operand of a conditional
285
+ // comparison or a switch/case label -- exactly the "branch on a release
286
+ // identifier" antipattern this gate exists to catch. A bare string literal
287
+ // elsewhere (a fixture path in a test, a usage example) does not match any
288
+ // of these and is not flagged.
289
+ function conditionalPatternsFor(id) {
290
+ const q = `["'\`]${id}["'\`]`;
291
+ return [
292
+ new RegExp(`===\\s*${q}`),
293
+ new RegExp(`${q}\\s*===`),
294
+ new RegExp(`[^=!]==\\s*${q}`),
295
+ new RegExp(`${q}\\s*==[^=]`),
296
+ new RegExp(`!==\\s*${q}`),
297
+ new RegExp(`${q}\\s*!==`),
298
+ new RegExp(`case\\s+${q}\\s*:`),
299
+ ];
300
+ }
301
+
302
+ // A CALL of the forbidden tool, as opposed to its name appearing in a
303
+ // comment, a doc string, or (elsewhere in the project, never under tools/) a
304
+ // deny-list array literal.
305
+ const DENY_LIST_CALL_PATTERN = /\bcall(?:Tool)?\s*\(\s*["'`]vice_disk_list["'`]/;
306
+
307
+ export function checkParameterisation({ toolsDir = SCAN_DIRS } = {}) {
308
+ const registry = loadRegistry();
309
+ const ids = registry.releases.map((r) => r.id);
310
+ const dirs = (Array.isArray(toolsDir) ? toolsDir : [toolsDir]).filter((d) => existsSync(d));
311
+ const files = dirs.flatMap((d) => listMjsFiles(d));
312
+
313
+ const violations = [];
314
+ const denyListCallViolations = [];
315
+
316
+ for (const file of files) {
317
+ const content = readFileSync(file, "utf8");
318
+ const relFile = rel(file);
319
+ for (const id of ids) {
320
+ for (const pattern of conditionalPatternsFor(id)) {
321
+ if (pattern.test(content)) {
322
+ violations.push({ file: relFile, release: id, pattern: pattern.source });
323
+ }
324
+ }
325
+ }
326
+ if (DENY_LIST_CALL_PATTERN.test(content)) {
327
+ denyListCallViolations.push(relFile);
328
+ }
329
+ }
330
+
331
+ return {
332
+ ok: violations.length === 0 && denyListCallViolations.length === 0,
333
+ filesScanned: files.length,
334
+ releaseIds: ids,
335
+ violations,
336
+ denyListCallViolations,
337
+ };
338
+ }
339
+
340
+ // -------------------------------------------------------------------- CLI
341
+
342
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
343
+ const [cmd, ...rest] = process.argv.slice(2);
344
+ const jsonFlag = rest.includes("--json");
345
+ const finalFlag = rest.includes("--final");
346
+
347
+ function run() {
348
+ if (cmd === "validate") {
349
+ const result = validateRegistry({ final: finalFlag });
350
+ if (jsonFlag) {
351
+ console.log(JSON.stringify(result, null, 2));
352
+ } else if (result.ok) {
353
+ console.log(`validate${finalFlag ? " --final" : ""}: OK (registry ${rel(registryPath)})`);
354
+ } else {
355
+ console.error(`validate${finalFlag ? " --final" : ""}: FAILED with ${result.errors.length} error(s):`);
356
+ for (const e of result.errors) console.error(` - ${e}`);
357
+ }
358
+ process.exitCode = result.ok ? 0 : 1;
359
+ return;
360
+ }
361
+ if (cmd === "check-parameterisation") {
362
+ const result = checkParameterisation();
363
+ if (jsonFlag) {
364
+ console.log(JSON.stringify(result, null, 2));
365
+ } else {
366
+ console.log(`check-parameterisation: scanned ${result.filesScanned} file(s) across the pipeline scripts dirs against release ids [${result.releaseIds.join(", ")}]`);
367
+ for (const v of result.violations) {
368
+ console.error(` - ${v.file}: release id "${v.release}" appears in a conditional (matched /${v.pattern}/)`);
369
+ }
370
+ for (const f of result.denyListCallViolations) {
371
+ console.error(` - ${f}: calls the forbidden vice_disk_list tool directly`);
372
+ }
373
+ console.log(result.ok ? "check-parameterisation: OK" : "check-parameterisation: FAILED");
374
+ }
375
+ process.exitCode = result.ok ? 0 : 1;
376
+ return;
377
+ }
378
+ console.log(`usage: node ${fileURLToPath(import.meta.url)} <validate [--final]|check-parameterisation> [--json]`);
379
+ process.exitCode = cmd ? 1 : 0;
380
+ }
381
+
382
+ run();
383
+ }
@@ -0,0 +1,306 @@
1
+ ---
2
+ name: c64-ram-capture
3
+ description: Capture a running C64's full 64K RAM as a verified flat image, and prove two captures are equivalent. Use when asked to dump RAM, depack a program by running it, capture a memory image at a checkpoint, or compare two captures for reproducibility.
4
+ ---
5
+
6
+ # Capturing and comparing C64 RAM
7
+
8
+ **Reach the emulator only through the `mcp__plugin_c64-re-tools_vice__*` tools.** They are the one
9
+ permitted route. Never open a connection by any other means.
10
+
11
+ **Never hand-assemble a capture.** Sixteen `vice_memory_read` calls have to land
12
+ contiguously and total exactly 65536 bytes; a dropped or short read is the normal
13
+ failure and it is invisible in a hex dump. Two committed modules do that byte work
14
+ and name the offending address when it is wrong.
15
+
16
+ ```bash
17
+ S=.claude/skills/c64-ram-capture/scripts # from the repo root
18
+ P=$S/d64-parse.mjs A=$S/dump-artifacts.mjs
19
+ C=$S/compare.mjs L=$S/releases.mjs
20
+
21
+ node $P directory --image path/to/image.d64 # what's on the disk (--json flags faked entries)
22
+ node $P bam --image path/to/image.d64 # disk name, DOS type, occupied track ranges
23
+ node $A assemble --chunks chunks.json # size + digest, writes nothing
24
+ node $A write-set --release <id> --label <label> \
25
+ --chunks chunks.json --raw raw.json # the four committed artifacts
26
+ node $L list # the valid --release ids
27
+
28
+ node $C digest dump.bin # sha256 + size, for the capture record
29
+ node $C compare a.bin b.bin # classify every difference, exit 1 on FAIL
30
+ node $C floor a.bin b.bin c.bin # drift floor across a capture set
31
+ ```
32
+
33
+ All three modules read only committed files and the JSON **you** wrote from your
34
+ own `mcp__plugin_c64-re-tools_vice__*` calls. They contact nothing.
35
+
36
+ ## The order
37
+
38
+ | # | Phase | Settles |
39
+ |---|---|---|
40
+ | 1 | Read the disk directory | Whether the release's entries are real or faked, before booting anything |
41
+ | 2 | Boot, confirm the PC moved | That the loader is actually executing |
42
+ | 3 | Checkpoint, hit, read 64K + chip state | The capture itself — all reads in one paused window |
43
+ | 4 | `write-set` | Assertions pass, four artifacts written, digest returned |
44
+ | 5 | Disarm, enumerate, resume once | That you left no checkpoint armed and the machine running |
45
+
46
+ ## Read the disk first
47
+
48
+ `scripts/d64-parse.mjs` parses `.d64` bytes directly, so it answers what is on the
49
+ disk whether or not the emulator is up:
50
+
51
+ ```bash
52
+ $ node $P directory --image demo.d64
53
+ PRG "DEMO GAME" first=5/0 blocks=5
54
+
55
+ $ node $P bam --image demo.d64
56
+ disk name: "DEMO DISK" id: 38 dos type: 2A
57
+ first dir sector: 18/1
58
+ occupied track ranges: 5
59
+ ```
60
+
61
+ Do not eyeball the directory for fakery — `--json` decides it. Every entry carries
62
+ `suspicious` plus `suspicious_reasons`, set when the block count is 0, when the
63
+ first track/sector falls outside the image, or when it points into a track the BAM
64
+ reports as entirely free. That last case is the signature of an entry claiming a
65
+ file never written to disk.
66
+
67
+ `scripts/d64-parse.test.mjs` proves the detector both **fires** on a synthetic
68
+ faked entry and stays silent on a well-formed one — a guard proven only silent is
69
+ not a guard. It also sweeps whatever real `.d64` corpus the project ships,
70
+ skipping when there is none. A non-null `chain_error` is the separate failure: a
71
+ directory chain that leaves the image or loops, reported instead of hanging.
72
+ **Confidence: HIGH** (synthetic fire-and-silence tests, plus a corpus sweep).
73
+
74
+ ## Boot a disk
75
+
76
+ 1. `mcp__plugin_c64-re-tools_vice__vice_disk_attach` with the disk image.
77
+ 2. `mcp__plugin_c64-re-tools_vice__vice_autostart` with the same image.
78
+ 3. `mcp__plugin_c64-re-tools_vice__vice_execution_run`.
79
+ 4. `mcp__plugin_c64-re-tools_vice__vice_registers_get` and confirm the program counter has moved.
80
+
81
+ If the program counter has not moved, type `LOAD"*",8,1` with
82
+ `mcp__plugin_c64-re-tools_vice__vice_keyboard_type`, run it, then type `RUN` and run it.
83
+
84
+ ## Capture at a trigger address
85
+
86
+ 1. `mcp__plugin_c64-re-tools_vice__vice_checkpoint_add` at the trigger address, with execution
87
+ breaking and stopping enabled.
88
+ 2. `mcp__plugin_c64-re-tools_vice__vice_execution_run`.
89
+ 3. Poll `mcp__plugin_c64-re-tools_vice__vice_ping` until the checkpoint reports a hit.
90
+ 4. Read `$0000`–`$FFFF` with repeated `mcp__plugin_c64-re-tools_vice__vice_memory_read` calls of
91
+ 4096 bytes each. Write them to `chunks.json` as an array of
92
+ `{ "address": "$0000", "hex": "..." }` records, one per call, hex only.
93
+ 5. Record the chip state in the **same paused window**, into `raw.json`. The keys
94
+ are fixed, because `chip-state` derives from exactly these: `registers`,
95
+ `sprites` and `cpu` pass through verbatim from
96
+ `mcp__plugin_c64-re-tools_vice__vice_vicii_get_state` / `mcp__plugin_c64-re-tools_vice__vice_sprite_get` /
97
+ `mcp__plugin_c64-re-tools_vice__vice_registers_get`; `port01_raw` is `$0001`; `dd00_raw` is
98
+ `$DD00`; `d018_raw` is `$D018`; `sprite_pointers` is the eight bytes at
99
+ `screen_base+$3F8`.
100
+ 6. Write all four artifacts in one call:
101
+
102
+ ```bash
103
+ node $A write-set --release <id> --label <label> \
104
+ --chunks chunks.json --raw raw.json
105
+ ```
106
+
107
+ It asserts exactly 65536 bytes with no gap and no overlap *before* writing
108
+ anything, then emits `<release>-<label>.bin`, `.state.json`, `.map.json` and
109
+ `.capture.json` under `recovery/<release>/dumps/`, and returns their paths
110
+ with the SHA-256. It also derives `vic_bank`, `screen_base`, `charset_base`
111
+ and `sprite_data_addresses` for free — do not recompute them by hand.
112
+ 7. `mcp__plugin_c64-re-tools_vice__vice_checkpoint_delete` the checkpoint.
113
+ 8. `mcp__plugin_c64-re-tools_vice__vice_checkpoint_list` and confirm it reports zero checkpoints.
114
+ Accept only this enumeration as proof. Record the count.
115
+ 9. `mcp__plugin_c64-re-tools_vice__vice_execution_run` to leave the machine running.
116
+
117
+ Read state before you resume, and resume exactly once at the end.
118
+
119
+ Hold keys down across a gate by releasing them at the trigger checkpoint in
120
+ step 3, never earlier.
121
+
122
+ `assemble` runs the same assertions and writes nothing, so it is the cheap check
123
+ on a set of chunks before committing them.
124
+
125
+ ## Worked example — a real capture
126
+
127
+ Chunks derived from a committed image, fed back through `assemble`:
128
+
129
+ ```
130
+ $ node $A assemble --chunks chunks.json
131
+ 65536 bytes, sha256 e1b8428c55bc7606b7e77846e8928bff23e9cf0c8241da479aadc1bc092faa26
132
+ ```
133
+
134
+ That digest is byte-identical to the `sha256` field committed in
135
+ that capture's own committed `.capture.json` sidecar, so the assembly path
136
+ reproduces a known-good artifact rather than merely producing 65536 bytes.
137
+ **Confidence: HIGH** (reproduced against the committed sidecar).
138
+
139
+ Then break it deliberately, to see what the guards say:
140
+
141
+ ```
142
+ $ node $A assemble --chunks gap.json # one chunk removed
143
+ Error: assembleImage: gap before address $3000 -- next chunk starts at $4000
144
+
145
+ $ node $A assemble --chunks short.json # last chunk truncated by 2 bytes
146
+ Error: assembleImage: assembled 65534 bytes ending at $FFFE, expected exactly 65536
147
+ ```
148
+
149
+ Read those as addresses to re-read, not as sizes to pad.
150
+
151
+ `manifest` on a fresh capture reports `classification_state: "ranges-only"` with
152
+ every range `unclassified`. That is correct and transient — it becomes `"bucketed"`
153
+ only after the provenance diff partitions loader from cracktro from game — see
154
+ `c64-provenance-diff`. A fresh capture already claiming `"bucketed"` is the anomaly.
155
+
156
+ ## Find an entry point
157
+
158
+ 1. Press past any "hit any key" gate with `mcp__plugin_c64-re-tools_vice__vice_keyboard_matrix`.
159
+ 2. Step forward in batches with `mcp__plugin_c64-re-tools_vice__vice_execution_step`, reading
160
+ `mcp__plugin_c64-re-tools_vice__vice_registers_get` after each batch.
161
+ 3. Stop when the program counter and the stack pointer both settle into a
162
+ repeating range across three consecutive batches. That range is the
163
+ dispatch loop; its lowest address is the entry point.
164
+ 4. Confirm the address with `mcp__plugin_c64-re-tools_vice__vice_disassemble` before recording it.
165
+
166
+ Set a batch ceiling before you start. Report failure to stabilise as a finding
167
+ with the batches spent; never extend the ceiling silently.
168
+
169
+ ## Prove the machine did not change under you
170
+
171
+ **Corrected 2026-08-04: there is no exposed tool that reads the epoch, and you do
172
+ not have to poll for one.** The proxy compares the restart epoch before *and*
173
+ after every forwarded call, and refuses the call — or discards its result, if the
174
+ change happened mid-call — with a loud error naming both epoch values. So the
175
+ capture's identity is guarded continuously, not at two sampled points.
176
+
177
+ What that leaves you:
178
+
179
+ - **A clean capture is one during which no epoch-drift error appeared.** Record
180
+ that, not a pair of hand-read numbers.
181
+ - **When you need the numbers,** they come from the drift error's own text, or
182
+ from `mcp__plugin_c64-re-tools_vice__vice_diagnose`'s `restarted` report. Both name the before and
183
+ after value.
184
+ - **A drift error voids the run** even if the very next call succeeds. It will —
185
+ the proxy re-baselines so the session stays usable — and a successful retry
186
+ after a respawn is talking to a freshly-booted machine.
187
+
188
+ `vice-wedge-triage` carries the decision tree for the other three ways a machine
189
+ stops answering.
190
+
191
+ **Void a run** whose machine identity you could not prove unchanged:
192
+
193
+ 1. Rename each artifact to `<name>.VOID-<UTC timestamp>`.
194
+ 2. Write a sibling note recording the reason, the time, and — if a drift error is
195
+ what voided it — the two epoch values quoted from that error. Do not go looking
196
+ for them; nothing reads the epoch on demand. Keep the voided artifacts on disk.
197
+
198
+ ## Compare two captures
199
+
200
+ Do not classify differences by hand — `scripts/compare.mjs` applies the rules
201
+ identically every time, and exits 1 on a FAIL so a script can gate on it:
202
+
203
+ ```bash
204
+ node $C compare capture-a.bin capture-b.bin
205
+ ```
206
+
207
+ ```
208
+ A capture-a.bin sha256 741213dcd1beb548b8896737f9f07e867c718dabeb56238862b9f3020e4902d2
209
+ B capture-b.bin sha256 ee3813322127b7bedf97abf3dd6ffcebb80c937f8b75dfe471c886fb36975573
210
+
211
+ volatile (excluded from the verdict): 100
212
+ $020A $9E %10011110 -> $8E %10001110 1 bit
213
+ … 99 more (--limit 0 for all)
214
+
215
+ drift — exactly one bit, reported as candidates: 61
216
+ $CC03 $00 %00000000 -> $20 %00100000 1 bit
217
+ … 60 more (--limit 0 for all)
218
+
219
+ DIVERGENCE — two or more bits, fails the comparison: 0
220
+
221
+ total differing addresses: 161 of 65536
222
+
223
+ VERDICT: PASS
224
+ Drift candidates present — pass, but record them with the capture.
225
+ ```
226
+
227
+ The three classes:
228
+
229
+ | Class | Rule | Effect on the verdict |
230
+ |---|---|---|
231
+ | volatile | `$0000-$0001`, `$0100-$01FF`, `$0200-$03FF`, **`$D000-$DFFF`** | counted and listed, never fails |
232
+ | drift | exactly one bit differs | listed as a candidate, passes |
233
+ | divergence | two or more bits differ | listed, **fails** |
234
+
235
+ **`$D000-$DFFF` is volatile because it is I/O, not RAM.** The VIC's registers
236
+ repeat every `$40` across `$D000-$D3FF` and the SID's across `$D400-$D7FF`, so
237
+ reading that range samples live hardware and two captures can never agree there.
238
+ Omitting it is what made the earlier hand-applied rule fail five of the six
239
+ committed gameentry pairings on `$D344`, `$D625` and `$D628` — differences that
240
+ were guaranteed. Region first, bit-count second.
241
+
242
+ `$E000-$FFFF` (RAM under KERNAL ROM when HIRAM = 0) is deliberately **not**
243
+ excluded. `$FAD8` and `$FC51` do differ across captures, but only two addresses
244
+ out of 8192 — too few for power-on garbage, and unexplained. They still fail, and
245
+ what writes them is an open question. Evidence and grading:
246
+ `.planning/RE-FINDINGS.md`, 2026-08-04.
247
+
248
+ **Establish a drift floor** with `floor` across every capture of one checkpoint.
249
+ It reports each address that differed in any pairing, with the distinct values
250
+ seen:
251
+
252
+ ```bash
253
+ node $C floor run1.bin run2.bin run3.bin
254
+ ```
255
+
256
+ Capture the power-on image as the very first action against a fresh machine, then
257
+ idle-capture twice more and run `floor` over the set. State the result as a
258
+ floor, not a complete set — more captures can only widen it.
259
+
260
+ ## Which skill does what
261
+
262
+ This one owns the image and its identity. It does not restate what the others carry.
263
+
264
+ | Need | Go to |
265
+ |---|---|
266
+ | Which address to read next, and what the answer rules out | `c64-program-recon` |
267
+ | Every way a live read gives a wrong answer | `c64-program-recon` — `references/observation-hazards.md`. **Read before driving.** |
268
+ | What a specific address or bit means | `c64-memory-mapping` — `node … lookup '$D018'` |
269
+ | Assembling, or a first-pass dead listing | `acme-build` |
270
+ | Whether a byte is original or cracker-changed, and what `bucketed` means | `c64-provenance-diff` |
271
+ | Whether the emulator is wedged, and whether it is safe to recycle | `vice-wedge-triage` |
272
+ | **A verified 64K image, or proving two captures equivalent** | here |
273
+
274
+ ## References
275
+
276
+ What this skill ships, and the committed modules it leans on. No `references/`
277
+ split: the workflow fits in one file, which is the right call when it does.
278
+
279
+ | Path | Covers |
280
+ |---|---|
281
+ | `scripts/compare.mjs` | Difference classification and the drift floor. Pure logic over captures you already have — `node $C` with no arguments prints the rules. |
282
+ | `templates/capture-record.template.md` | The per-capture record: identity, machine state read in the same paused window, the void checklist, and the per-pairing comparison table. |
283
+ | `scripts/d64-parse.mjs` | `.d64` directory, BAM, and `--json` fakery detection. Fixture-tested against both real images by `scripts/d64-parse.test.mjs`. |
284
+ | `scripts/dump-artifacts.mjs` | `assemble` / `chip-state` / `manifest` / `write-set` — the guarded byte work, and the source of every `assembleImage:` message in the table below. |
285
+
286
+ Findings that make RE faster go in `.planning/RE-FINDINGS.md` **at the moment you
287
+ find them**, graded with `Evidence:` and `Confidence:`. Promote by re-logging with
288
+ the new evidence, never by editing a grade in place. File-changing work enters
289
+ through a GSD command (`/gsd-quick`).
290
+
291
+ ## Troubleshooting
292
+
293
+ | Symptom | Fix |
294
+ |---|---|
295
+ | `assembleImage: gap before address $3000 -- next chunk starts at $4000` | A `vice_memory_read` never landed. Re-read that 4096-byte window; do not pad it. |
296
+ | `assembleImage: overlap at address $8000 -- a previous chunk already covered up to $8003` | Two chunks cover the same window, usually a duplicated call after a retry. Drop the duplicate. |
297
+ | `assembleImage: assembled 65534 bytes ending at $FFFE, expected exactly 65536` | A read returned short. Re-read the final window. |
298
+ | `unknown release "x" -- known releases: …` | The `--release` id is not in the registry. The error names the valid ids; it throws before writing anything. |
299
+ | A fresh `.map.json` says `classification_state: "bucketed"` | Wrong — a fresh capture is `"ranges-only"`. The provenance diff sets `"bucketed"`, nothing else. |
300
+ | The checkpoint never fired | Most state reads pause the emulator. Resume exactly once, at the end, after every read. |
301
+ | Two captures of the same checkpoint differ | Expected. Full-64K identity is impossible in principle; run `compare` and read the verdict rather than judging by eye. |
302
+ | `compare` fails on an address in `$D000`-`$DFFF` | It cannot — that range is volatile. If you are seeing this, you applied the rules by hand; use `scripts/compare.mjs`. |
303
+ | `compare` fails on `$FAD8` or `$FC51` only | Known and unexplained: RAM under KERNAL ROM, two addresses out of 8192. Record it with the capture rather than voiding a set that is otherwise clean. |
304
+ | `--limit 0` printed nothing | Fixed 2026-08-04 — it now means unlimited. Re-pull the script if you see the old behaviour. |
305
+ | An epoch-drift error appeared mid-capture | The machine restarted under you. Void the run; do not salvage the artifacts. The next call succeeding does not undo it. |
306
+ | The emulator looks dead | `vice-wedge-triage` — and enumerate your own armed checkpoints before concluding anything. |