@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.
- package/README.md +61 -0
- package/bin/cli.mjs +226 -0
- package/package.json +53 -0
- package/skills/acme-build/SKILL.md +224 -0
- package/skills/acme-build/scripts/acme.mjs +263 -0
- package/skills/acme-build/template.a +39 -0
- package/skills/c64-memory-mapping/SKILL.md +199 -0
- package/skills/c64-memory-mapping/memmap.json +8800 -0
- package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
- package/skills/c64-program-recon/SKILL.md +172 -0
- package/skills/c64-program-recon/references/control-flow.md +174 -0
- package/skills/c64-program-recon/references/graphics.md +73 -0
- package/skills/c64-program-recon/references/observation-hazards.md +118 -0
- package/skills/c64-program-recon/references/reconstruction.md +128 -0
- package/skills/c64-program-recon/references/sound-and-input.md +68 -0
- package/skills/c64-program-recon/references/tool-selection.md +55 -0
- package/skills/c64-program-recon/scripts/derive.mjs +364 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
- package/skills/c64-provenance-diff/SKILL.md +257 -0
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
- package/skills/c64-ram-capture/SKILL.md +306 -0
- package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
- package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
- package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
- package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
- 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. |
|