@henols/c64-re-tools 0.2.4 → 0.2.5
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/package.json +2 -2
- package/skills/acme-build/SKILL.md +22 -22
- package/skills/c64-disk-access/SKILL.md +49 -46
- package/skills/c64-memory-mapping/SKILL.md +71 -70
- package/skills/c64-petcat/SKILL.md +11 -11
- package/skills/c64-program-recon/SKILL.md +109 -105
- package/skills/c64-program-recon/references/control-flow.md +13 -13
- package/skills/c64-program-recon/references/graphics.md +6 -6
- package/skills/c64-program-recon/references/observation-hazards.md +7 -7
- package/skills/c64-program-recon/references/reconstruction.md +6 -6
- package/skills/c64-program-recon/references/sound-and-input.md +8 -8
- package/skills/c64-program-recon/references/tool-selection.md +11 -11
- package/skills/c64-program-recon/templates/memory-map.template.md +16 -12
- package/skills/c64-provenance-diff/SKILL.md +43 -43
- package/skills/c64-ram-capture/SKILL.md +96 -45
- package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +525 -0
- package/skills/c64-ram-capture/templates/capture-record.template.md +11 -11
- package/skills/c64-ram-capture/transients/README.md +11 -10
- package/skills/routine-queue-walker/SKILL.md +205 -176
- package/skills/vice-wedge-triage/SKILL.md +48 -48
|
@@ -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.
|
|
4
|
-
capture**, before the machine
|
|
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
|
|
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
|
|
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
|
|
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` —
|
|
61
|
-
- [ ] The `capture route` row
|
|
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
|
|
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
|
|
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
|
|
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[]
|
|
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.
|
|
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**.
|
|
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
|
|
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
|
|
79
|
-
`--cap` **only ever narrows
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|