@henols/c64-re-tools 0.2.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ // Cross-binary RAM/chip-state comparison: classify every difference between
3
+ // two captures of DIFFERENT binaries and print a single VERDICT line.
4
+ //
5
+ // Pure logic. This module reads files the agent already produced and does
6
+ // arithmetic over them. It contacts nothing: the mcp__plugin_c64-re-tools_vice__*
7
+ // tools are the only route to the emulator (.claude/CLAUDE.md § Emulator
8
+ // Access), and nothing here opens a connection, reads broker state, or shells
9
+ // out.
10
+ //
11
+ // The narrowed volatile mask below is committed here, in this commit, before
12
+ // any rebuild is compared under it (ROADMAP Phase 50 criterion 1). The mask
13
+ // may be narrowed further later. It must never be widened just to make a
14
+ // rebuild pass -- the same discipline `compare.mjs` and `derive-transients.mjs`
15
+ // already carry for their own masks/allow-lists.
16
+ //
17
+ // This is a SIBLING of `compare.mjs`, not an extension of it. `compare.mjs`'s
18
+ // own rules -- a blanket $D000-$DFFF mask and a one-bit "drift" tolerance --
19
+ // are correct for its own job: two captures of the SAME binary at the same
20
+ // checkpoint. Both rules are wrong for this module's job, which is two
21
+ // captures of DIFFERENT binaries, where a one-bit difference at a masked
22
+ // address (e.g. `lda #$02` -> `lda #$03` ahead of `sta $d020`) is exactly the
23
+ // kind of regression a rebuild must not be allowed to hide. This module does
24
+ // not import from, edit, or re-export `compare.mjs`; its rules stay frozen
25
+ // for its own, still-correct, same-binary job. The three-bucket shape and the
26
+ // classification core are duplicated here DELIBERATELY, not shared, so a
27
+ // future edit to one module's rules cannot silently change the other's.
28
+ //
29
+ // Three rule departures from compare.mjs, each load-bearing:
30
+ //
31
+ // (a) NO DRIFT BUCKET. Across two different binaries a one-bit difference
32
+ // is a real difference. `drift` exists to absorb sampling noise between
33
+ // two runs of the SAME binary; applied across binaries it would absorb
34
+ // an `lda #$02` to `lda #$03` regression whole. Every non-volatile,
35
+ // non-allowlisted difference is a divergence here, regardless of bit
36
+ // count.
37
+ // (b) NARROWED I/O MASK. `compare.mjs` masks $D000-$DFFF entirely. This
38
+ // module masks only the addresses and register fields that genuinely
39
+ // cannot be stable, listed individually below with a reason each.
40
+ // (c) ROUTE AWARENESS. A capture carries a declared route of "snapshot" or
41
+ // "memory-read". On the snapshot route the bytes at $D000-$DFFF in the
42
+ // image are RAM under I/O, not the register read view (SKILL.md,
43
+ // "Slice the image out of a snapshot instead of transcribing it"); on
44
+ // the memory-read route they are the register read view. Comparing one
45
+ // against the other is meaningless, so this module refuses the pair.
46
+ //
47
+ // Departure from compare.mjs's own CLI-dispatch shape: the dispatch tail at
48
+ // the bottom of this file is GUARDED
49
+ // (`if (process.argv[1] === fileURLToPath(import.meta.url))`), unlike
50
+ // compare.mjs's unguarded tail. This module exports IMAGE_VOLATILE and
51
+ // IO_VOLATILE for reuse by this file's own test file and by later Phase 50
52
+ // plans; an unguarded tail would call process.exit() the instant anything
53
+ // imports this module, killing whatever process did the importing.
54
+ // `dump-artifacts.mjs`, the newer script in this same directory, already
55
+ // carries the identical guard for the identical reason.
56
+
57
+ import { readFileSync } from "node:fs";
58
+ import { createHash } from "node:crypto";
59
+ import { basename, resolve } from "node:path";
60
+ import { fileURLToPath } from "node:url";
61
+
62
+ const IMAGE_BYTES = 65536;
63
+
64
+ // A hand-bumped version string, printed into every report as
65
+ // `MASK_NARROWED_AT:` so a transcript records which mask produced it. Bump
66
+ // this whenever a mask span in IMAGE_VOLATILE or IO_VOLATILE changes --
67
+ // never silently, and never to make a rebuild pass.
68
+ const MASK_VERSION = "compare-cross-binary-mask-v1";
69
+
70
+ // ---------------------------------------------------------------- IMAGE_VOLATILE
71
+
72
+ // Spans of the 64K image that are excluded from the verdict on every route.
73
+ // Carried forward from compare.mjs's own VOLATILE table, same reasons.
74
+ export const IMAGE_VOLATILE = [
75
+ { lo: 0x0000, hi: 0x0001, reason: "CPU port" },
76
+ { lo: 0x0100, hi: 0x01ff, reason: "stack page" },
77
+ { lo: 0x0200, hi: 0x03ff, reason: "KERNAL work area / BASIC input buffer" },
78
+ ];
79
+
80
+ // ------------------------------------------------------------------- IO_VOLATILE
81
+
82
+ // Register offsets and ranges that stay excluded, each with its own one-line
83
+ // hardware reason. The VIC-II only decodes 6 address bits, so its registers
84
+ // at $D000-$D02E mirror every $40 bytes across the whole $D000-$D3FF window
85
+ // -- entries here for that window are expressed as the CANONICAL address in
86
+ // the first $D000-$D03F block and folded across all sixteen mirrors by
87
+ // (addr & 0x3f) in isIoVolatile()/imageMaskEntryFor() below, so a regression
88
+ // at $D020 is caught at $D020 and at all fifteen of its mirrors.
89
+ //
90
+ // Everything else in $D000-$D3FF -- $D000-$D010, $D013-$D018, $D01A-$D01D
91
+ // and $D020-$D02E -- is deliberately NOT masked. $D015, $D018 and $D020 are
92
+ // the three ROADMAP criterion 1 names inside that carve-out.
93
+ const VIC_MIRRORED_VOLATILE = [
94
+ {
95
+ lo: 0xd011,
96
+ hi: 0xd011,
97
+ reason: "bit 7 is the raster counter's ninth bit, so the whole byte is masked",
98
+ },
99
+ { lo: 0xd012, hi: 0xd012, reason: "the raster counter" },
100
+ { lo: 0xd019, hi: 0xd019, reason: "the interrupt latch" },
101
+ { lo: 0xd01e, hi: 0xd01f, reason: "the collision latches, cleared by reading them" },
102
+ ];
103
+
104
+ // Ranges outside the VIC-II mirrored window. These do not mirror the same
105
+ // way -- SID/colour-RAM/CIA/expansion each occupy their own flat span -- so
106
+ // no offset-folding applies; the whole range is excluded.
107
+ const FLAT_VOLATILE = [
108
+ { lo: 0xd400, hi: 0xd7ff, reason: "SID is write-only in hardware, read-back is unrecoverable" },
109
+ { lo: 0xd800, hi: 0xdbff, reason: "colour RAM whose high nibble is open bus" },
110
+ {
111
+ lo: 0xdc00,
112
+ hi: 0xdcff,
113
+ reason: "CIA1 -- timers, time-of-day and interrupt-control registers all move without the program touching them",
114
+ },
115
+ {
116
+ lo: 0xdd00,
117
+ hi: 0xddff,
118
+ reason: "CIA2 -- timers, time-of-day and interrupt-control registers all move without the program touching them",
119
+ },
120
+ { lo: 0xde00, hi: 0xdfff, reason: "I/O expansion area and reads open bus" },
121
+ ];
122
+
123
+ export const IO_VOLATILE = [...VIC_MIRRORED_VOLATILE, ...FLAT_VOLATILE];
124
+
125
+ function ioMaskEntryFor(addr) {
126
+ if (addr >= 0xd000 && addr <= 0xd3ff) {
127
+ const canonical = 0xd000 + (addr & 0x3f);
128
+ return VIC_MIRRORED_VOLATILE.find(({ lo, hi }) => canonical >= lo && canonical <= hi) ?? null;
129
+ }
130
+ return FLAT_VOLATILE.find(({ lo, hi }) => addr >= lo && addr <= hi) ?? null;
131
+ }
132
+
133
+ export function isIoVolatile(addr) {
134
+ return ioMaskEntryFor(addr) !== null;
135
+ }
136
+
137
+ function imageMaskEntryFor(addr, route) {
138
+ const base = IMAGE_VOLATILE.find(({ lo, hi }) => addr >= lo && addr <= hi);
139
+ if (base) return base;
140
+ if (route === "memory-read" && addr >= 0xd000 && addr <= 0xdfff) {
141
+ return ioMaskEntryFor(addr);
142
+ }
143
+ // snapshot route: $D000-$DFFF in the image is ordinary RAM under I/O, not
144
+ // the register read view -- not masked at all. See SKILL.md, "Slice the
145
+ // image out of a snapshot instead of transcribing it".
146
+ return null;
147
+ }
148
+
149
+ export function isImageVolatile(addr, route) {
150
+ return imageMaskEntryFor(addr, route) !== null;
151
+ }
152
+
153
+ // -------------------------------------------------------------------- format
154
+
155
+ const hex4 = (n) => "$" + n.toString(16).toUpperCase().padStart(4, "0");
156
+ const hex2 = (n) => "$" + n.toString(16).toUpperCase().padStart(2, "0");
157
+ const bin8 = (n) => "%" + n.toString(2).padStart(8, "0");
158
+
159
+ function loadImage(path) {
160
+ const buf = readFileSync(path);
161
+ if (buf.length !== IMAGE_BYTES) {
162
+ throw new Error(`${path}: ${buf.length} bytes, expected ${IMAGE_BYTES} — not a full 64K image`);
163
+ }
164
+ return buf;
165
+ }
166
+
167
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
168
+
169
+ // ---------------------------------------------------------------------- state
170
+
171
+ // --state <a.state.json> loads a per-capture sidecar this module defines its
172
+ // own way -- distinct from, but inspired by, `dump-artifacts.mjs`'s
173
+ // chip-state output shape (`registers`/`sprites`/`cpu`), since no committed
174
+ // chip-state sidecar carries $Dxxx register values directly today. The shape
175
+ // this module reads:
176
+ //
177
+ // {
178
+ // "route": "snapshot" | "memory-read",
179
+ // "registers": { "$D011": 27, "53272": 21, "0xD020": 14 }
180
+ // }
181
+ //
182
+ // `registers` keys may be a "$Dxxx" hex string, a "0xNNNN" hex string, or a
183
+ // bare decimal string -- whatever the caller already has to hand. Every key
184
+ // must parse as an address or the sidecar is refused by name. `registers` is
185
+ // optional: a capture with no chip-state evidence compares on the image
186
+ // alone.
187
+ function loadState(path) {
188
+ return JSON.parse(readFileSync(path, "utf8"));
189
+ }
190
+
191
+ function parseAddrKey(k) {
192
+ if (/^\$[0-9a-fA-F]+$/.test(k)) return parseInt(k.slice(1), 16);
193
+ if (/^0x[0-9a-fA-F]+$/i.test(k)) return parseInt(k, 16);
194
+ if (/^\d+$/.test(k)) return parseInt(k, 10);
195
+ return null;
196
+ }
197
+
198
+ function normalizeRegisters(stateDoc, path) {
199
+ const map = new Map();
200
+ const raw = stateDoc?.registers;
201
+ if (!raw) return map;
202
+ for (const [k, v] of Object.entries(raw)) {
203
+ const addr = parseAddrKey(k);
204
+ if (addr === null) {
205
+ throw new Error(`${path}: register key "${k}" is not a parseable address -- refused`);
206
+ }
207
+ map.set(addr, v);
208
+ }
209
+ return map;
210
+ }
211
+
212
+ // ----------------------------------------------------------------- allowlist
213
+
214
+ // --allowlist <path> loads the intentional-difference document. Shape:
215
+ //
216
+ // {
217
+ // "checkpoint": "hazard_raster_entry",
218
+ // "subjects": { "hazard-subject-modified": "<sha256 of the .prg>" },
219
+ // "entries": [
220
+ // { "start": 8221, "endInclusive": 8221, "domain": "image", "why": "..." }
221
+ // ]
222
+ // }
223
+ //
224
+ // Every entry is validated at load time and refused BY NAME on: a missing or
225
+ // whitespace-only `why`; a `start` above `endInclusive`; a range that
226
+ // intersects a masked span (mask wins -- an allowlist can never re-admit a
227
+ // hardware-volatile address, so a real difference can never be smuggled in
228
+ // under a masked address's cover); and a document `checkpoint` that does not
229
+ // match the checkpoint the two captures themselves declare. This is
230
+ // semantically distinct from the mask tables above -- hardware noise versus a
231
+ // deliberate difference -- and is kept in its own bucket, never folded into
232
+ // IMAGE_VOLATILE/IO_VOLATILE, so ROADMAP criterion 2's distinction stays
233
+ // visible in the record.
234
+ function loadAllowlist(path, { route, checkpointName }) {
235
+ const doc = JSON.parse(readFileSync(path, "utf8"));
236
+ const entries = doc.entries ?? [];
237
+
238
+ if (checkpointName && doc.checkpoint && doc.checkpoint !== checkpointName) {
239
+ throw new Error(
240
+ `${path}: allowlist declares checkpoint "${doc.checkpoint}", captures declare "${checkpointName}" -- refused`,
241
+ );
242
+ }
243
+
244
+ for (const e of entries) {
245
+ const rangeLabel =
246
+ e.start === e.endInclusive ? hex4(e.start) : `${hex4(e.start)}-${hex4(e.endInclusive)}`;
247
+ if (!e.why || !String(e.why).trim()) {
248
+ throw new Error(
249
+ `${path}: allowlist entry ${rangeLabel} has no why -- an allowlist entry without a non-empty why string is refused`,
250
+ );
251
+ }
252
+ if (!(e.start <= e.endInclusive)) {
253
+ throw new Error(`${path}: allowlist entry ${rangeLabel} has start above endInclusive -- refused`);
254
+ }
255
+ const domain = e.domain === "register" ? "register" : "image";
256
+ for (let addr = e.start; addr <= e.endInclusive; addr++) {
257
+ const maskEntry = domain === "register" ? ioMaskEntryFor(addr) : imageMaskEntryFor(addr, route);
258
+ if (maskEntry) {
259
+ throw new Error(
260
+ `${path}: allowlist entry ${rangeLabel} (${domain}) overlaps the masked span ` +
261
+ `${hex4(maskEntry.lo)}-${hex4(maskEntry.hi)} (${maskEntry.reason}) -- overlap between an ` +
262
+ `allowlist entry and the volatile mask is refused`,
263
+ );
264
+ }
265
+ }
266
+ }
267
+
268
+ return { checkpoint: doc.checkpoint ?? null, subjects: doc.subjects ?? {}, entries };
269
+ }
270
+
271
+ function findAllowlistEntry(allowlist, addr, domain) {
272
+ if (!allowlist) return null;
273
+ for (const e of allowlist.entries) {
274
+ const eDomain = e.domain === "register" ? "register" : "image";
275
+ if (eDomain !== domain) continue;
276
+ if (addr >= e.start && addr <= e.endInclusive) return e;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ // -------------------------------------------------------------- classification
282
+
283
+ /**
284
+ * Classify every differing address between two captures -- image bytes plus,
285
+ * when both sides carry a chip-state sidecar, register values. Precedence is
286
+ * volatile first, then allowlisted, then divergence -- the same "mask wins
287
+ * over everything else" precedence compare.mjs already uses for isVolatile,
288
+ * extended one step further: allowlist wins over bit-count too. There is no
289
+ * fourth bucket and no bit-count branch: every non-volatile,
290
+ * non-allowlisted difference is a divergence, one bit or many.
291
+ */
292
+ export function classify({ imgA, imgB, route, regMapA, regMapB, allowlist }) {
293
+ const volatile_ = [];
294
+ const allowlisted = [];
295
+ const divergence = [];
296
+
297
+ for (let addr = 0; addr < IMAGE_BYTES; addr++) {
298
+ const x = imgA[addr];
299
+ const y = imgB[addr];
300
+ if (x === y) continue;
301
+
302
+ const rec = { addr, a: x, b: y, domain: "image" };
303
+ if (isImageVolatile(addr, route)) {
304
+ volatile_.push(rec);
305
+ continue;
306
+ }
307
+ const aw = findAllowlistEntry(allowlist, addr, "image");
308
+ if (aw) {
309
+ allowlisted.push({ ...rec, why: aw.why });
310
+ continue;
311
+ }
312
+ divergence.push(rec);
313
+ }
314
+
315
+ if (regMapA && regMapB && regMapA.size && regMapB.size) {
316
+ const addrs = new Set([...regMapA.keys(), ...regMapB.keys()]);
317
+ for (const addr of [...addrs].sort((p, q) => p - q)) {
318
+ const x = regMapA.get(addr);
319
+ const y = regMapB.get(addr);
320
+ // Present on only one side -- not comparable, not counted either way.
321
+ if (x === undefined || y === undefined) continue;
322
+ if (x === y) continue;
323
+
324
+ const rec = { addr, a: x, b: y, domain: "register" };
325
+ if (isIoVolatile(addr)) {
326
+ volatile_.push(rec);
327
+ continue;
328
+ }
329
+ const aw = findAllowlistEntry(allowlist, addr, "register");
330
+ if (aw) {
331
+ allowlisted.push({ ...rec, why: aw.why });
332
+ continue;
333
+ }
334
+ divergence.push(rec);
335
+ }
336
+ }
337
+
338
+ return { volatile: volatile_, allowlisted, divergence, pass: divergence.length === 0 };
339
+ }
340
+
341
+ // ------------------------------------------------------------------- printing
342
+
343
+ const fmtDiffRow = (r) =>
344
+ ` ${hex4(r.addr)} ${hex2(r.a)} ${bin8(r.a)} -> ${hex2(r.b)} ${bin8(r.b)} [${r.domain}]`;
345
+
346
+ function printList(title, rows, limit) {
347
+ console.log(`\n${title}: ${rows.length}`);
348
+ if (!rows.length) return;
349
+ // --limit 0 means unlimited, matching the usage text. Anything else caps.
350
+ const shown = limit ? rows.slice(0, limit) : rows;
351
+ for (const r of shown) {
352
+ const why = r.why ? ` -- ${r.why}` : "";
353
+ console.log(fmtDiffRow(r) + why);
354
+ }
355
+ if (shown.length < rows.length) {
356
+ console.log(` … ${rows.length - shown.length} more (--limit 0 for all)`);
357
+ }
358
+ }
359
+
360
+ // ---------------------------------------------------------------------- cross
361
+
362
+ function parseCrossArgs(argv) {
363
+ const positional = [];
364
+ let statePaths = null;
365
+ let allowlistPath = null;
366
+ let noAllowlist = false;
367
+ let checkpointAssert = null;
368
+ let routeAssert = null;
369
+ let limit;
370
+
371
+ for (let i = 0; i < argv.length; i++) {
372
+ const a = argv[i];
373
+ if (a === "--state") {
374
+ statePaths = [argv[++i], argv[++i]];
375
+ } else if (a === "--allowlist") {
376
+ allowlistPath = argv[++i];
377
+ } else if (a === "--no-allowlist") {
378
+ noAllowlist = true;
379
+ } else if (a === "--checkpoint") {
380
+ checkpointAssert = argv[++i];
381
+ } else if (a === "--route") {
382
+ routeAssert = argv[++i];
383
+ } else if (a === "--limit") {
384
+ const n = Number(argv[++i]);
385
+ if (!Number.isInteger(n) || n < 0) throw new Error("--limit needs a non-negative integer");
386
+ limit = n;
387
+ } else {
388
+ positional.push(a);
389
+ }
390
+ }
391
+
392
+ if (positional.length !== 2) throw new Error("cross needs exactly two image paths");
393
+ return {
394
+ imagePaths: positional,
395
+ statePaths,
396
+ allowlistPath,
397
+ noAllowlist,
398
+ checkpointAssert,
399
+ routeAssert,
400
+ limit,
401
+ };
402
+ }
403
+
404
+ function cmdCross(argv) {
405
+ const opts = parseCrossArgs(argv);
406
+ const [pa, pb] = opts.imagePaths;
407
+ const imgA = loadImage(pa);
408
+ const imgB = loadImage(pb);
409
+
410
+ const stateA = opts.statePaths ? loadState(opts.statePaths[0]) : null;
411
+ const stateB = opts.statePaths ? loadState(opts.statePaths[1]) : null;
412
+
413
+ const routeA = stateA?.route ?? opts.routeAssert ?? "memory-read";
414
+ const routeB = stateB?.route ?? opts.routeAssert ?? "memory-read";
415
+ if (routeA !== routeB) {
416
+ throw new Error(
417
+ `capture routes differ -- A declares "${routeA}", B declares "${routeB}". Comparing a ` +
418
+ `snapshot-route capture against a memory-read-route capture is meaningless; refused.`,
419
+ );
420
+ }
421
+ const route = routeA;
422
+
423
+ const checkpointA = stateA?.checkpoint_name ?? null;
424
+ const checkpointB = stateB?.checkpoint_name ?? null;
425
+ if (checkpointA && checkpointB && checkpointA !== checkpointB) {
426
+ throw new Error(
427
+ `logical checkpoints differ -- A resolved "${checkpointA}", B resolved "${checkpointB}". A ` +
428
+ `comparison whose two captures name different logical checkpoints is refused.`,
429
+ );
430
+ }
431
+ const checkpointName = checkpointA ?? checkpointB ?? null;
432
+ if (opts.checkpointAssert && checkpointName && opts.checkpointAssert !== checkpointName) {
433
+ throw new Error(
434
+ `--checkpoint asserted "${opts.checkpointAssert}", captures declare "${checkpointName}" -- refused`,
435
+ );
436
+ }
437
+
438
+ const regMapA = stateA ? normalizeRegisters(stateA, opts.statePaths[0]) : null;
439
+ const regMapB = stateB ? normalizeRegisters(stateB, opts.statePaths[1]) : null;
440
+
441
+ const allowlist =
442
+ opts.allowlistPath && !opts.noAllowlist
443
+ ? loadAllowlist(opts.allowlistPath, { route, checkpointName })
444
+ : null;
445
+
446
+ const haA = sha256(imgA);
447
+ const haB = sha256(imgB);
448
+
449
+ console.log(`A ${basename(pa)} sha256 ${haA}`);
450
+ console.log(`B ${basename(pb)} sha256 ${haB}`);
451
+ console.log(`MASK_NARROWED_AT: ${MASK_VERSION}`);
452
+ if (checkpointA) {
453
+ console.log(`A checkpoint ${checkpointA} @ ${hex4(stateA.checkpoint_address ?? 0)}`);
454
+ }
455
+ if (checkpointB) {
456
+ console.log(`B checkpoint ${checkpointB} @ ${hex4(stateB.checkpoint_address ?? 0)}`);
457
+ }
458
+
459
+ const byteIdentical = haA === haB;
460
+ console.log(`\nBYTE_IDENTICAL: ${byteIdentical ? "yes" : "no"}`);
461
+ console.log(" (a recorded extra -- the VERDICT line below is the acceptance signal, not this one)");
462
+
463
+ // Always classify, even when the images are byte-identical: a chip-state-
464
+ // only regression must still be caught. Unlike compare.mjs's cmdCompare(),
465
+ // there is no early return on equal digests here -- see this plan's
466
+ // assumption_delta_decision, which demotes byte-identity to a recorded
467
+ // extra and promotes behavioural equivalence (the full classification) to
468
+ // the actual acceptance criterion.
469
+ const r = classify({ imgA, imgB, route, regMapA, regMapB, allowlist });
470
+
471
+ printList("volatile (excluded from the verdict)", r.volatile, opts.limit);
472
+ printList("allowlisted (intentional difference, excluded from the verdict)", r.allowlisted, opts.limit);
473
+ printList("DIVERGENCE — fails the comparison", r.divergence, opts.limit);
474
+
475
+ const total = r.volatile.length + r.allowlisted.length + r.divergence.length;
476
+ console.log(`\ntotal differing addresses (image + register): ${total}`);
477
+ console.log(`\nVERDICT: ${r.pass ? "PASS" : "FAIL"}`);
478
+ return r.pass ? 0 : 1;
479
+ }
480
+
481
+ // -------------------------------------------------------------------- dispatch
482
+
483
+ const commands = { cross: cmdCross };
484
+
485
+ function usage() {
486
+ return `usage: node compare-cross-binary.mjs <command>
487
+
488
+ cross <a.bin> <b.bin> [--state <a.state.json> <b.state.json>]
489
+ [--allowlist <path>] [--no-allowlist] [--checkpoint <name>]
490
+ [--route <snapshot|memory-read>] [--limit N]
491
+ classify every difference between two captures of DIFFERENT binaries and
492
+ print a single VERDICT line.
493
+
494
+ No drift bucket here: every non-volatile, non-allowlisted difference fails,
495
+ regardless of bit count -- unlike compare.mjs, which is for two captures of
496
+ the SAME binary. Volatile registers (excluded): $D011, $D012, $D019,
497
+ $D01E-$D01F, $D400-$D7FF, $D800-$DBFF, $DC00-$DCFF, $DD00-$DDFF, $DE00-$DFFF
498
+ (mirrored across $D000-$D3FF where applicable). $D015, $D018 and $D020 are
499
+ deliberately NOT masked.
500
+ --limit 0 prints every row. Exit status is 1 on a FAIL verdict, non-zero on
501
+ any refusal (mismatched route, mismatched checkpoint, a malformed allowlist,
502
+ a malformed image).
503
+
504
+ Captures come from the procedure in c64-ram-capture/SKILL.md, via
505
+ mcp__plugin_c64-re-tools_vice__*. This script contacts nothing.`;
506
+ }
507
+
508
+ function main() {
509
+ const [cmd, ...rest] = process.argv.slice(2);
510
+ if (!cmd || !commands[cmd]) {
511
+ console.error(usage());
512
+ process.exit(cmd ? 1 : 0);
513
+ return;
514
+ }
515
+ try {
516
+ process.exit(commands[cmd](rest));
517
+ } catch (e) {
518
+ console.error(`error: ${e.message}`);
519
+ process.exit(1);
520
+ }
521
+ }
522
+
523
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
524
+ main();
525
+ }
@@ -1,14 +1,14 @@
1
1
  # Capture record — `<release>-<checkpoint>-run<N>`
2
2
 
3
- One record per capture. Every field is recorded **in the same step as the
4
- capture**, before the machine is resumed — a value read later describes a
3
+ One record per capture. The reader records every field **in the same step as the
4
+ capture**, before resuming the machine — a value read later describes a
5
5
  different machine.
6
6
 
7
7
  Every `node scripts/…` command cited below is relative to **this skill's own
8
8
  directory** (`src/skills/c64-ram-capture/`), not to the repository root. From
9
9
  the repo root, use the `$S`-derived variables `SKILL.md`'s quick-reference
10
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
11
+ slice …`. Stated once here because five rows use the shorthand, and
12
12
  `scripts/` resolves from neither cwd by accident.
13
13
 
14
14
  ## Identity
@@ -16,11 +16,11 @@ slice …`. Stated once here because the shorthand is used in five rows and
16
16
  | Field | Value | How obtained |
17
17
  |---|---|---|
18
18
  | image path | `recovery/<release>/dumps/<name>.bin` | — |
19
- | size | `65536` bytes | must be exact; anything else is not a full image |
19
+ | size | `65536` bytes | must be exact. Anything else is not a full image |
20
20
  | sha256 | `<64 hex chars>` | `node scripts/compare.mjs digest <name>.bin` |
21
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
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 |
23
+ | seed | `4242` | the `-seed` value the broker set when it launched the instance — `STOCK_DETERMINISM_SEED` in `src/mcp/vice/broker-launch.mts`. Cite the constant, not a loose number |
24
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 |
25
25
  | checkpoint / trigger address | `$____` | the address armed for this capture |
26
26
  | release | the registry id this capture belongs to | — |
@@ -57,13 +57,13 @@ Read these *before* resuming, in the same paused window as the memory reads.
57
57
  ## Verdict
58
58
 
59
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.
60
+ - [ ] All three reproducibility-key fields — `binary sha256`, `argv digest`, `seed` — hold a value. A blank in any one of them fails this box.
61
+ - [ ] The `capture route` row holds a value, so a reader knows which volatility rule this record is under.
62
62
  - [ ] No epoch-drift error appeared at any point during the capture.
63
63
  - [ ] `vice_checkpoint_list` reported zero checkpoints before resuming.
64
64
  - [ ] Machine resumed exactly once, at the end.
65
65
 
66
- If any box is unchecked, void the run: rename each artifact to
66
+ If any box stays unchecked, void the run: rename each artifact to
67
67
  `<name>.VOID-<UTC timestamp>`, write a sibling note giving the reason and — when a
68
68
  drift error was the cause — both epoch values quoted from that error's own text,
69
69
  and keep the voided artifacts on disk.
@@ -84,15 +84,15 @@ complete set — more captures of the same checkpoint can only widen it.
84
84
 
85
85
  ### `$D000-$DFFF`: the rule depends on the `capture route` row
86
86
 
87
- This exclusion is a property of **how the image was transcribed**, not of the
87
+ This exclusion is a property of **how you transcribed the image**, not of the
88
88
  C64. Read the `capture route` row and apply the matching half. Getting this
89
89
  backwards costs a good capture in one direction and hides 4096 addresses of real
90
90
  divergence in the other.
91
91
 
92
92
  | `capture route` | Rule for `$D000-$DFFF` |
93
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. |
94
+ | `memory-read` | **Any divergence inside `$D000-$DFFF` is not a divergence.** On this route, `vice_memory_read` reads the range and samples live I/O. The VIC's registers repeat every `$40` across `$D000-$D3FF`, and the SID's registers repeat every `$40` across `$D400-$D7FF`. 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[]`. This array is RAM *under* I/O, 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
96
 
97
97
  Both halves are live: records taken on the memory-read route still exist and
98
98
  still need the first row. What is not permitted is applying either half without
@@ -17,7 +17,8 @@ Verbatim, because this is the part that is meant to survive:
17
17
 
18
18
  - **N >= 3 runs** of the **same release** under the **same protocol** at the
19
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.
20
+ verified capture. `derive` refuses fewer than three runs, naming the count
21
+ and the minimum.
21
22
  - The allow-list is the **union of addresses differing across the pairwise
22
23
  comparisons** — every pairing, which is N(N-1)/2 for N images, not just the
23
24
  adjacent ones.
@@ -26,8 +27,8 @@ Verbatim, because this is the part that is meant to survive:
26
27
  empty otherwise, so an unattributed transient is visibly unattributed rather
27
28
  than absent).
28
29
  - 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`.
30
+ between releases**. `derive` refuses a re-derivation over an existing
31
+ artifact without an explicit `--force`.
31
32
  - The list is **enumerated** — one entry per address, never a range, a span, a
32
33
  page or a region. `parseAllowList()` in
33
34
  `src/mcp/vice/capture-predicate.ts` refuses range-shaped keys by name.
@@ -67,7 +68,7 @@ node $S/derive-transients.mjs check --allow-list $T/<id>.json runA.bin runB.bin
67
68
 
68
69
  `entries` is ascending by address, so the artifact is diffable and an entry's
69
70
  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
+ is an integer. `values` are the distinct bytes seen at it, in hex, across every
71
72
  pairing it differed in. This is exactly the shape `parseAllowList()` accepts —
72
73
  there is no translation step between the derivation and the predicate, and the
73
74
  derivation's colocated test asserts the round-trip rather than assuming it.
@@ -75,9 +76,9 @@ derivation's colocated test asserts the round-trip rather than assuming it.
75
76
  ## The cap is 64, and exceeding it VOIDS the derivation
76
77
 
77
78
  `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.
79
+ one definition. A test asserts the script's default cap equal to it.
80
+ `--cap` **only ever narrows**. `derive` refuses a value above the committed cap
81
+ by name.
81
82
 
82
83
  **Over the cap is not "the list is a bit long".** It means **the stop is not
83
84
  frame-exact**, and that is a fact the gate must hear rather than a threshold to
@@ -103,7 +104,7 @@ measured on this host:
103
104
  Read the second row carefully: **overflow is an observed outcome, not a
104
105
  hypothetical.** A frame-anchored autostarted stop has already been measured
105
106
  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
+ better stop, recorded together with the jitter value — not a bigger cap.
107
108
 
108
109
  The 1242 row is also the reason the seed is not the whole story: that stop had
109
110
  the determinism block applied and still differed at 1242 bytes, because it was
@@ -112,7 +113,7 @@ anchor close, not what the seed does.
112
113
 
113
114
  ## `$0000` / `$0001` do not belong in a derived list by hand
114
115
 
115
- The 6510 port overlay is normalised **in code**, once, by `normalisePorts()` on
116
+ `normalisePorts()` normalises the 6510 port overlay **in code**, once, on
116
117
  the snapshot route. Spending two of the cap's 64 slots on those two addresses
117
118
  would hide a real difference behind a known one. They can legitimately appear in
118
119
  a derivation taken from un-normalised images — `derive` compares the bytes it is
@@ -123,7 +124,7 @@ the images, not a rule to add.
123
124
 
124
125
  `.gitignore` here refuses every image byte form — flat captures, disk and tape
125
126
  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
+ and this project committed it **before the first derivation existed**. A directory that
127
128
  starts refusing images after the first capture lands has already had one
128
129
  commit's worth of opportunity to leak one. JSON is deliberately not refused:
129
130
  the derived allow-lists are the artifacts this directory is for.