@henols/c64-re-tools 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bin/cli.mjs +7 -4
  2. package/package.json +2 -2
  3. package/skills/acme-build/SKILL.md +39 -23
  4. package/skills/acme-build/scripts/acme.mjs +159 -64
  5. package/skills/acme-build/template.a +1 -1
  6. package/skills/c64-disk-access/SKILL.md +156 -0
  7. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  8. package/skills/c64-memory-mapping/SKILL.md +30 -23
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  10. package/skills/c64-petcat/SKILL.md +87 -0
  11. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  12. package/skills/c64-program-recon/SKILL.md +93 -39
  13. package/skills/c64-program-recon/references/control-flow.md +12 -15
  14. package/skills/c64-program-recon/references/graphics.md +1 -1
  15. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  16. package/skills/c64-program-recon/references/reconstruction.md +1 -2
  17. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  18. package/skills/c64-program-recon/references/tool-selection.md +36 -17
  19. package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
  20. package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
  21. package/skills/c64-provenance-diff/SKILL.md +40 -5
  22. package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
  23. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
  24. package/skills/c64-ram-capture/SKILL.md +112 -44
  25. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  26. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  28. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  29. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  30. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  31. package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
  32. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  33. package/skills/c64-ram-capture/transients/README.md +136 -0
  34. package/skills/routine-queue-walker/SKILL.md +114 -22
  35. package/skills/routine-queue-walker/scripts/completeness-report.mjs +465 -0
  36. package/skills/vice-wedge-triage/SKILL.md +96 -89
  37. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+ // Derive a per-release transient allow-list from N >= 3 captures of the same
3
+ // stop, and re-check a pair against an already-committed derivation.
4
+ //
5
+ // Pure logic. This module reads images the agent already captured and does
6
+ // arithmetic over them. It contacts nothing: the mcp__plugin_c64-re-tools_vice__* tools are the
7
+ // only route to the emulator, and nothing here opens a connection, reads
8
+ // broker state, or spawns any process at all -- not even the interpreter
9
+ // already running it.
10
+ //
11
+ // THE METHOD IS THE DELIVERABLE, AND THE ADDRESS SET NEVER IS. A real
12
+ // release's transients are its own frame counters, RNG state,
13
+ // sprite positions and music-player pointers. An allow-list inherited from
14
+ // another release cannot be distinguished afterwards from one honestly
15
+ // derived, which is why this script re-derives per release and refuses to
16
+ // overwrite an existing artifact without an explicit `--force`. What carries
17
+ // forward between releases is this file, never its output.
18
+ //
19
+ // THE RULES IT IMPLEMENTS, in full:
20
+ //
21
+ // N >= 3 fewer than three images is refused, naming the count and the
22
+ // minimum. Three runs is already this project's documented
23
+ // minimum for a verified capture.
24
+ // union the allow-list is the union of addresses differing across
25
+ // EVERY pairwise comparison -- N(N-1)/2 pairs for N images.
26
+ // per entry the address, which run pairs it differed in (by basename, so
27
+ // the artifact is readable), the distinct byte values seen, and
28
+ // a one-line attribution left empty for a human to fill.
29
+ // cap 64 exceeding it VOIDS the derivation: non-zero exit, no artifact,
30
+ // and the message states the stop is not frame-exact. It is not
31
+ // a threshold to raise. `--cap` only ever NARROWS -- a value
32
+ // above the committed cap is refused by name, so the flag cannot
33
+ // be used to launder an overflow into a pass.
34
+ //
35
+ // THE TWO RULES IT DOES NOT INHERIT FROM ITS SIBLING, stated explicitly
36
+ // because `compare.mjs` sits in this same directory and reads on the same
37
+ // captures:
38
+ //
39
+ // * NO ADDRESS RANGE IS EVER A VOLATILE SPAN HERE, at any address, under any
40
+ // name. `compare.mjs` excludes four ranges covering 4866 addresses
41
+ // (`$0000-$0001`, `$0100-$01FF`, `$0200-$03FF`, `$D000-$DFFF`). The rule
42
+ // requires an ENUMERATED list and never a range, and the artifact this
43
+ // script writes carries one entry per address for exactly that reason.
44
+ // * THERE IS NO BIT-COUNT TOLERANCE HERE, at any address, in any form.
45
+ // `compare.mjs` classifies a one-bit difference as "drift" and lets it
46
+ // pass anywhere. A one-bit difference outside the allow-list FAILS here.
47
+ // The cost of getting this wrong is recorded in
48
+ // `src/mcp/vice/capture-predicate.ts`'s header: a predicate carrying
49
+ // either inherited rule passes the phase's one-bit fail-ability control,
50
+ // so the control goes green having proven nothing.
51
+ //
52
+ // `$D000-$DFFF` IS NOT VOLATILE HERE EITHER, and that is route-specific rather
53
+ // than arbitrary. `compare.mjs`'s 4096-address exclusion is a property of the
54
+ // memory-READ route, where reading that range samples live I/O registers and
55
+ // two reads can never agree. A `.vsf`-sliced image is the `C64MEM` array --
56
+ // RAM *under* I/O, not the register read view -- so on the snapshot route the
57
+ // exclusion disappears and a difference there is a real difference.
58
+ //
59
+ // WHY IT CARRIES ITS OWN COMPARISON INSTEAD OF CALLING THE MCP-SIDE ONE.
60
+ // `src/mcp/vice/capture-predicate.ts` is the authoritative predicate, and
61
+ // `vsf-slice.mjs` in this directory shows the route a skill script takes to
62
+ // reach the MCP tree: spawn the interpreter on a CLI entry point. That route
63
+ // is unavailable here -- `capture-predicate.ts` is a pure library with no CLI
64
+ // entry point, a static cross-package import resolves on neither npm-installer
65
+ // route (see `vsf-slice.mjs`'s header for the measured constraint), and
66
+ // `scripts/check-npm-packages.mjs`'s transitive closure walk would fail the
67
+ // pack for one. So this is the retired skill-side/MCP-side disk-image-reader
68
+ // pair's own answer rather than the `vsf-slice.mjs`
69
+ // answer: a second independent implementation of a rule that
70
+ // is STABLE and TINY -- set membership over differing addresses, with no
71
+ // ranges, no tolerances and no version-sensitive layout anywhere in it. The
72
+ // agreement between the two is not left to trust: `derive-transients.test.mjs`
73
+ // imports `compareCaptures()` over the same resolution ladder `vsf-slice.mjs`
74
+ // uses and asserts that `check`'s verdict matches it on a synthetic pair,
75
+ // skipping with a NAMED reason if the MCP tree is absent.
76
+ //
77
+ // WHAT NOT TO DO:
78
+ // - Never raise the cap because a derivation overflowed it. An overflow
79
+ // means the stop is not frame-exact. MEASURED, for scale: 0
80
+ // differing addresses at a frame-exact `READY`-prompt stop, 66 at a
81
+ // jitter-4000 autostarted stop, 300 at a wall-clock autostarted stop on a
82
+ // real release, 1242 at a wall-clock `READY` stop with the determinism
83
+ // block applied. Over-cap is a real, observed outcome and not a
84
+ // hypothetical, and the answer to it is a better stop.
85
+ // - Never truncate the union to fit the cap. A truncated allow-list makes
86
+ // every later comparison pass on bytes nobody vetted, which is the silent
87
+ // widening the void exists to prevent. The artifact is written once, in
88
+ // full, only after the cap check clears.
89
+ // - Never add `$0000`/`$0001` to a derived allow-list by hand. They are
90
+ // normalised in code by `normalisePorts()` on the snapshot route,
91
+ // and spending two of the cap's 64 slots on them would hide a real
92
+ // difference behind a known one. They can legitimately appear in a
93
+ // derivation taken from un-normalised images -- see `check`'s note below.
94
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
95
+ import { createHash } from "node:crypto";
96
+ import { basename } from "node:path";
97
+
98
+ const IMAGE_BYTES = 65536;
99
+
100
+ /** The committed maximum number of addresses an allow-list may enumerate
101
+ * This literal MUST equal `TRANSIENT_ALLOW_LIST_CAP` in
102
+ * `src/mcp/vice/capture-predicate.ts`; the colocated test asserts that
103
+ * equality against the IMPORTED constant rather than against a second copy of
104
+ * the number, so the two cannot drift apart silently. */
105
+ const TRANSIENT_ALLOW_LIST_CAP = 64;
106
+
107
+ /** The committed minimum number of runs a derivation takes. */
108
+ const MIN_RUNS = 3;
109
+
110
+ /** The artifact format version. Bump it when the entry shape changes, never
111
+ * when an address set changes -- address sets are per-release data, not
112
+ * schema. */
113
+ const SCHEMA_VERSION = "1.0";
114
+
115
+ const hex4 = (n) => "$" + n.toString(16).toUpperCase().padStart(4, "0");
116
+ const hex2 = (n) => "$" + n.toString(16).toUpperCase().padStart(2, "0");
117
+
118
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
119
+
120
+ /** The range-shaped keys an entry must never carry, byte-identical to
121
+ * `parseAllowList()`'s own list. Refused BY NAME on the way in AND on the way
122
+ * out: an entry written as a span is an author reaching for the very rule
123
+ * this list exists to enforce. */
124
+ const RANGE_SHAPED_KEYS = [
125
+ "start",
126
+ "end",
127
+ "from",
128
+ "to",
129
+ "range",
130
+ "span",
131
+ "lo",
132
+ "hi",
133
+ "first",
134
+ "last",
135
+ ];
136
+
137
+ // ---------------------------------------------------------------- argv
138
+
139
+ /** Which flags each verb accepts, and whether each takes a value.
140
+ *
141
+ * A REAL PARSER, DELIBERATELY, and not `compare.mjs`'s
142
+ * `argv.filter(s => !s.startsWith("--"))` idiom. That filter treats a flag's
143
+ * VALUE as a positional, so `--out probe.json a.bin b.bin c.bin` would derive
144
+ * from four images, one of which is a JSON path. With value-taking flags in
145
+ * the signature that idiom is a defect rather than a shortcut. */
146
+ const FLAG_SPEC = {
147
+ derive: { "--release": "value", "--out": "value", "--cap": "value", "--force": "boolean" },
148
+ check: { "--allow-list": "value", "--limit": "value" },
149
+ };
150
+
151
+ function parseArgv(verb, argv) {
152
+ const spec = FLAG_SPEC[verb];
153
+ const flags = Object.create(null);
154
+ const positionals = [];
155
+
156
+ for (let i = 0; i < argv.length; i++) {
157
+ const tok = argv[i];
158
+ if (!tok.startsWith("--")) {
159
+ positionals.push(tok);
160
+ continue;
161
+ }
162
+ const kind = spec[tok];
163
+ if (!kind) {
164
+ throw new Error(
165
+ `${verb}: unknown flag ${tok} -- accepted: ${Object.keys(spec).join(", ")}`,
166
+ );
167
+ }
168
+ if (kind === "boolean") {
169
+ flags[tok] = true;
170
+ continue;
171
+ }
172
+ const value = argv[i + 1];
173
+ if (value === undefined || value.startsWith("--")) {
174
+ throw new Error(`${verb}: ${tok} needs a value`);
175
+ }
176
+ flags[tok] = value;
177
+ i++;
178
+ }
179
+
180
+ return { flags, positionals };
181
+ }
182
+
183
+ function requiredFlag(verb, flags, name) {
184
+ const v = flags[name];
185
+ if (typeof v !== "string" || v.trim() === "") {
186
+ throw new Error(`${verb}: ${name} is required and must not be empty`);
187
+ }
188
+ return v;
189
+ }
190
+
191
+ // ---------------------------------------------------------------- images
192
+
193
+ /** Load one image, refusing anything that is not exactly a full 64K capture,
194
+ * naming the path and the length it actually had. Refused rather than
195
+ * compared: two short buffers agree at every address they have. */
196
+ function loadImage(path) {
197
+ const buf = readFileSync(path);
198
+ if (buf.length !== IMAGE_BYTES) {
199
+ throw new Error(
200
+ `${path}: ${buf.length} bytes, expected exactly ${IMAGE_BYTES} -- not a full 64K image, and ` +
201
+ `refusing rather than deriving from a partial one, which agrees everywhere it has no bytes`,
202
+ );
203
+ }
204
+ return buf;
205
+ }
206
+
207
+ /** Load every image, in the order given, refusing a basename collision.
208
+ *
209
+ * The pair attributions in the artifact are written by basename, so two
210
+ * images sharing one makes an entry's provenance unreadable -- and passing the
211
+ * same path twice would add a pair that differs nowhere and inflate the pair
212
+ * count with a comparison of an image against itself. */
213
+ function loadImages(verb, paths) {
214
+ if (paths.length < MIN_RUNS) {
215
+ throw new Error(
216
+ `${verb}: ${paths.length} image(s) given, minimum ${MIN_RUNS} -- the committed method is ` +
217
+ `N >= ${MIN_RUNS} runs of the same release under the same protocol at the same stop, and three ` +
218
+ `runs is already this project's documented minimum for a verified capture`,
219
+ );
220
+ }
221
+ const byName = new Map();
222
+ const imgs = [];
223
+ for (const path of paths) {
224
+ const name = basename(path);
225
+ const already = byName.get(name);
226
+ if (already !== undefined) {
227
+ throw new Error(
228
+ `${verb}: two images share the basename "${name}" (${already} and ${path}) -- the pair ` +
229
+ `attributions are written by basename, so a collision makes an entry's provenance unreadable`,
230
+ );
231
+ }
232
+ byName.set(name, path);
233
+ imgs.push({ path, name, buf: loadImage(path) });
234
+ }
235
+ return imgs;
236
+ }
237
+
238
+ // ---------------------------------------------------------------- derivation
239
+
240
+ /**
241
+ * The committed derivation: the union of addresses differing across EVERY
242
+ * pairwise comparison of N images. Returns the union as an ascending entry
243
+ * array plus the pair count, and classifies nothing -- there is no volatile
244
+ * span and no bit-count tolerance anywhere in it.
245
+ */
246
+ function deriveUnion(imgs) {
247
+ const union = new Map(); // addr -> { pairs: string[], values: Set<number> }
248
+ const pairs = [];
249
+
250
+ for (let i = 0; i < imgs.length; i++) {
251
+ for (let j = i + 1; j < imgs.length; j++) {
252
+ const label = `${imgs[i].name} vs ${imgs[j].name}`;
253
+ pairs.push(label);
254
+ const a = imgs[i].buf;
255
+ const b = imgs[j].buf;
256
+ let n = 0;
257
+ for (let addr = 0; addr < IMAGE_BYTES; addr++) {
258
+ const x = a[addr];
259
+ const y = b[addr];
260
+ if (x === y) continue;
261
+ n++;
262
+ let rec = union.get(addr);
263
+ if (!rec) {
264
+ rec = { pairs: [], values: new Set() };
265
+ union.set(addr, rec);
266
+ }
267
+ rec.pairs.push(label);
268
+ rec.values.add(x);
269
+ rec.values.add(y);
270
+ }
271
+ console.log(`PAIR ${label}: ${n} differing address${n === 1 ? "" : "es"}`);
272
+ }
273
+ }
274
+
275
+ // Ascending by address, so the artifact is diffable and an entry's position
276
+ // never depends on which pair happened to observe it first.
277
+ const addresses = [...union.keys()].sort((x, y) => x - y);
278
+ const entries = addresses.map((address) => {
279
+ const rec = union.get(address);
280
+ return {
281
+ address,
282
+ pairs: rec.pairs,
283
+ values: [...rec.values].sort((p, q) => p - q).map(hex2),
284
+ // Left empty for a human to fill. An empty string is a legal
285
+ // attribution and round-trips through `parseAllowList()`; the field
286
+ // exists so an unattributed transient is visibly unattributed rather
287
+ // than absent.
288
+ attribution: "",
289
+ };
290
+ });
291
+
292
+ return { entries, pairs };
293
+ }
294
+
295
+ function capFrom(flags) {
296
+ if (flags["--cap"] === undefined) return TRANSIENT_ALLOW_LIST_CAP;
297
+ const n = Number(flags["--cap"]);
298
+ if (!Number.isInteger(n) || n < 1) {
299
+ throw new Error("derive: --cap needs a positive integer");
300
+ }
301
+ if (n > TRANSIENT_ALLOW_LIST_CAP) {
302
+ throw new Error(
303
+ `derive: --cap ${n} is above the committed cap of ${TRANSIENT_ALLOW_LIST_CAP} -- the cap is a ` +
304
+ `pre-commitment, and raising it after seeing a derivation overflow converts a measurement into ` +
305
+ `an excuse. --cap only ever NARROWS`,
306
+ );
307
+ }
308
+ return n;
309
+ }
310
+
311
+ function cmdDerive(argv) {
312
+ const { flags, positionals } = parseArgv("derive", argv);
313
+ const release = requiredFlag("derive", flags, "--release");
314
+ const out = requiredFlag("derive", flags, "--out");
315
+ const cap = capFrom(flags);
316
+
317
+ // Checked BEFORE any image is read, so the no-inheritance refusal costs
318
+ // nothing and cannot be reached halfway through a derivation.
319
+ if (existsSync(out) && flags["--force"] !== true) {
320
+ throw new Error(
321
+ `derive: ${out} already exists. An allow-list is re-derived per release from that release's own ` +
322
+ `runs, and no address set is ever inherited between releases -- an inherited list cannot be ` +
323
+ `distinguished afterwards from an honestly derived one, so a contaminated ledger has to be ` +
324
+ `re-derived from fresh captures. Pass --force to replace this artifact with a fresh derivation ` +
325
+ `of "${release}"`,
326
+ );
327
+ }
328
+
329
+ const imgs = loadImages("derive", positionals);
330
+ for (const i of imgs) console.log(`${i.name} sha256 ${sha256(i.buf)}`);
331
+
332
+ const { entries, pairs } = deriveUnion(imgs);
333
+
334
+ // The count IS the finding, on both paths, so it is printed on both. What
335
+ // differs is that the void path writes nothing.
336
+ console.log(`TRANSIENT_COUNT: ${entries.length}`);
337
+ console.log(`cap: ${cap} (committed cap ${TRANSIENT_ALLOW_LIST_CAP})`);
338
+
339
+ if (entries.length > cap) {
340
+ console.error(
341
+ `VOID: the derivation over ${imgs.length} runs of release "${release}" yields ${entries.length} ` +
342
+ `differing addresses, above the cap of ${cap}.\n` +
343
+ `The derivation is VOID. What that means is not "the list is a bit long": it means THE STOP IS ` +
344
+ `NOT FRAME-EXACT, and that is a fact the gate must hear rather than a threshold to move. ` +
345
+ `Re-derive from a frame-anchored stop and record this count as measured; do not raise the cap ` +
346
+ `and do not truncate the union to fit it, which would make every later comparison pass on bytes ` +
347
+ `nobody vetted.\n` +
348
+ `No artifact was written to ${out}.`,
349
+ );
350
+ return 1;
351
+ }
352
+
353
+ const artifact = {
354
+ schema_version: SCHEMA_VERSION,
355
+ release,
356
+ derived_from: imgs.map((i) => i.name),
357
+ pair_count: pairs.length,
358
+ cap,
359
+ method:
360
+ `union of addresses differing across every pairwise comparison of N >= ${MIN_RUNS} runs of one ` +
361
+ `release at one stop; re-derived per release, never inherited`,
362
+ entries,
363
+ };
364
+
365
+ // Written ONCE, in full, only after the cap check cleared. No partial file
366
+ // exists at any point on the void path.
367
+ writeFileSync(out, JSON.stringify(artifact, null, 2) + "\n");
368
+ console.log(`wrote ${out}: release "${release}", ${entries.length} entries, ${pairs.length} pairs`);
369
+ return 0;
370
+ }
371
+
372
+ // ---------------------------------------------------------------- check
373
+
374
+ /** Parse a committed artifact, with the refusals `parseAllowList()` raises, in
375
+ * the same order -- the cap FIRST, because exceeding it voids the whole
376
+ * derivation and there is nothing to gain from validating the entries of a
377
+ * list that cannot be used. */
378
+ function parseArtifact(json, path) {
379
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
380
+ throw new Error(`${path}: an allow-list artifact must be a JSON object with a release and entries`);
381
+ }
382
+ if (typeof json.release !== "string" || json.release.trim() === "") {
383
+ throw new Error(
384
+ `${path}: the artifact must name the release it was derived from -- an allow-list is re-derived ` +
385
+ `per release and never inherited, so the identifier travels with it`,
386
+ );
387
+ }
388
+ if (!Array.isArray(json.entries)) {
389
+ throw new Error(`${path}: the artifact must carry an entries array -- an enumerated list of addresses`);
390
+ }
391
+ if (json.entries.length > TRANSIENT_ALLOW_LIST_CAP) {
392
+ throw new Error(
393
+ `${path}: the allow-list enumerates ${json.entries.length} addresses, above the committed cap of ` +
394
+ `${TRANSIENT_ALLOW_LIST_CAP} -- exceeding the cap VOIDS the derivation (it means the stop is not ` +
395
+ `frame-exact) and is never repaired by raising the cap`,
396
+ );
397
+ }
398
+
399
+ const addresses = new Set();
400
+ for (let i = 0; i < json.entries.length; i++) {
401
+ const entry = json.entries[i];
402
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
403
+ throw new Error(`${path}: entry ${i} must be an object carrying an address and its run pairs`);
404
+ }
405
+ for (const key of RANGE_SHAPED_KEYS) {
406
+ if (key in entry) {
407
+ throw new Error(
408
+ `${path}: entry ${i} carries the range-shaped key "${key}" -- the transient allow-list is ` +
409
+ `ENUMERATED and is never a range, so write each address as its own entry`,
410
+ );
411
+ }
412
+ }
413
+ const addr = entry.address;
414
+ if (Array.isArray(addr)) {
415
+ throw new Error(
416
+ `${path}: entry ${i} writes its address as an array, which is a range-shaped span -- the ` +
417
+ `transient allow-list is ENUMERATED and is never a range`,
418
+ );
419
+ }
420
+ if (!Number.isInteger(addr) || addr < 0 || addr > 0xffff) {
421
+ throw new Error(`${path}: entry ${i} has address ${JSON.stringify(addr)}, not an integer in 0..65535`);
422
+ }
423
+ if (addresses.has(addr)) {
424
+ throw new Error(
425
+ `${path}: entry ${i} repeats address ${hex4(addr)} -- a duplicate silently consumes a second ` +
426
+ `slot of the cap`,
427
+ );
428
+ }
429
+ if (!Array.isArray(entry.pairs) || entry.pairs.some((p) => typeof p !== "string")) {
430
+ throw new Error(
431
+ `${path}: entry ${i} (address ${hex4(addr)}) must record which run pairs it differed in, as an ` +
432
+ `array of strings`,
433
+ );
434
+ }
435
+ // The MCP-side parseAllowList() refuses a non-string
436
+ // `attribution` and this parser did not check the field at all, so a
437
+ // hand-edited artifact with `"attribution": 5` passed `check` here and was
438
+ // refused there. The two implementations are a DELIBERATE duplicate
439
+ // asserted to agree on VERDICTS, and parse strictness is exactly where a
440
+ // deliberate duplicate drifts first -- an artifact one accepts and the
441
+ // other rejects is a disagreement about what the ledger even says.
442
+ if (entry.attribution !== undefined && typeof entry.attribution !== "string") {
443
+ throw new Error(
444
+ `${path}: entry ${i} (address ${hex4(addr)}) has a non-string attribution ` +
445
+ `(${JSON.stringify(entry.attribution)}) -- an attribution is a one-line human note or absent`,
446
+ );
447
+ }
448
+ addresses.add(addr);
449
+ }
450
+
451
+ return { release: json.release, addresses };
452
+ }
453
+
454
+ /**
455
+ * The equivalence rule, in full, with nothing else in it: a differing address
456
+ * ON the allow-list is counted, reported and excluded from the verdict. ANY
457
+ * other differing address FAILS -- whatever its bit count, wherever it sits,
458
+ * and however close it lies to an allow-listed address. The allow-list is a
459
+ * SET OF ADDRESSES and never a neighbourhood.
460
+ *
461
+ * Symmetric in `a` and `b`: the verdict depends only on WHICH addresses
462
+ * differ, never on which image was passed first.
463
+ */
464
+ function compareUnderAllowList(a, b, addresses) {
465
+ const allowed = [];
466
+ const divergence = [];
467
+ for (let addr = 0; addr < IMAGE_BYTES; addr++) {
468
+ const x = a[addr];
469
+ const y = b[addr];
470
+ if (x === y) continue;
471
+ (addresses.has(addr) ? allowed : divergence).push({ addr, a: x, b: y });
472
+ }
473
+ return {
474
+ allowed,
475
+ divergence,
476
+ verdict: divergence.length === 0 ? "equivalent" : "not-equivalent",
477
+ };
478
+ }
479
+
480
+ function printRows(title, rows, limit) {
481
+ console.log(`\n${title}: ${rows.length}`);
482
+ const shown = limit === 0 ? rows : rows.slice(0, limit);
483
+ for (const r of shown) console.log(` ${hex4(r.addr)} ${hex2(r.a)} -> ${hex2(r.b)}`);
484
+ if (shown.length < rows.length) {
485
+ console.log(` ... ${rows.length - shown.length} more (--limit 0 for all)`);
486
+ }
487
+ }
488
+
489
+ function cmdCheck(argv) {
490
+ const { flags, positionals } = parseArgv("check", argv);
491
+ const listPath = requiredFlag("check", flags, "--allow-list");
492
+ const limit = flags["--limit"] === undefined ? 40 : Number(flags["--limit"]);
493
+ if (!Number.isInteger(limit) || limit < 0) throw new Error("check: --limit needs a non-negative integer");
494
+ if (positionals.length !== 2) {
495
+ throw new Error(`check: needs exactly two image paths, got ${positionals.length}`);
496
+ }
497
+
498
+ // The JSON.parse used to sit OUTSIDE parseArtifact(), so a syntax
499
+ // error surfaced through the outer catch as a bare `error: Unexpected token
500
+ // …` naming no file -- unlike every other refusal in this script, each of
501
+ // which names the path. On a route whose whole subject is which artifact
502
+ // says what, "which file failed to parse" is the first thing an operator
503
+ // needs.
504
+ let listJson;
505
+ const listRaw = readFileSync(listPath, "utf8");
506
+ try {
507
+ listJson = JSON.parse(listRaw);
508
+ } catch (e) {
509
+ throw new Error(`${listPath}: not valid JSON -- ${e.message}`);
510
+ }
511
+ const { release, addresses } = parseArtifact(listJson, listPath);
512
+ const [pa, pb] = positionals;
513
+ const a = loadImage(pa);
514
+ const b = loadImage(pb);
515
+
516
+ console.log(`allow-list ${listPath}: release "${release}", ${addresses.size} addresses (cap ${TRANSIENT_ALLOW_LIST_CAP})`);
517
+ console.log(`A ${basename(pa)} sha256 ${sha256(a)}`);
518
+ console.log(`B ${basename(pb)} sha256 ${sha256(b)}`);
519
+
520
+ const r = compareUnderAllowList(a, b, addresses);
521
+ printRows("allowed (enumerated transients, excluded from the verdict)", r.allowed, limit);
522
+ printRows("DIVERGENCE -- outside the allow-list, fails at any bit count", r.divergence, limit);
523
+
524
+ console.log(`\nCHECK_VERDICT: ${r.verdict}`);
525
+ return r.verdict === "equivalent" ? 0 : 1;
526
+ }
527
+
528
+ // ---------------------------------------------------------------- CLI
529
+
530
+ // Prototype-less, via Object.create(null). A plain object
531
+ // literal inherits Object.prototype, so `commands["constructor"]` and
532
+ // `commands["toString"]` are truthy FUNCTIONS: an unknown verb that happens to
533
+ // be a prototype member passed the known-verb test and was then CALLED, so the
534
+ // documented usage was never printed and the failure surfaced as a confusing
535
+ // message from process.exit() about its argument type instead. Same idiom this
536
+ // file already uses for its flag bag, applied one level up.
537
+ const commands = Object.assign(Object.create(null), { derive: cmdDerive, check: cmdCheck });
538
+
539
+ const [cmd, ...rest] = process.argv.slice(2);
540
+ if (!cmd || !commands[cmd]) {
541
+ console.error(`usage: node derive-transients.mjs <command>
542
+
543
+ derive --release <id> --out <path> [--cap N] [--force] <a.bin> <b.bin> <c.bin> [...]
544
+ Derive the per-release transient allow-list: the union of addresses differing across
545
+ every pairwise comparison of N >= ${MIN_RUNS} captures of one release at one stop.
546
+ Prints TRANSIENT_COUNT: <n>. Over the cap of ${TRANSIENT_ALLOW_LIST_CAP} the derivation
547
+ is VOID -- non-zero exit, no artifact written, because the stop is not frame-exact.
548
+
549
+ check --allow-list <path> [--limit N] <a.bin> <b.bin>
550
+ Re-check one pair against an already-committed derivation, without re-deriving it.
551
+ Prints CHECK_VERDICT: equivalent | not-equivalent. Exit 1 when not equivalent.
552
+
553
+ The allow-list is ENUMERATED, never a range, and there is no bit-count tolerance at any
554
+ address: a one-bit difference outside the list FAILS. \`compare.mjs\`'s volatile spans and
555
+ its drift-passes rule are NOT inherited here -- see this file's header for why, and for why
556
+ \`$D000-$DFFF\` is not volatile on the snapshot route.
557
+
558
+ The method is re-derived per release and NO address set is ever inherited between releases;
559
+ re-deriving over an existing artifact is refused without --force. \`--cap\` only narrows.
560
+
561
+ \`check\` compares the images exactly as given. On the snapshot route the \`$0000\`/\`$0001\`
562
+ 6510-port overlay is normalised in code by the MCP-side predicate, so a derivation
563
+ taken from un-normalised images can legitimately carry those two addresses.
564
+
565
+ Images come from the capture procedure in this skill's SKILL.md. This script contacts
566
+ nothing and spawns nothing.`);
567
+ process.exit(cmd ? 1 : 0);
568
+ }
569
+
570
+ try {
571
+ process.exit(commands[cmd](rest));
572
+ } catch (e) {
573
+ console.error(`error: ${e.message}`);
574
+ process.exit(1);
575
+ }
@@ -55,7 +55,7 @@ export function assembleImage(chunks) {
55
55
  return total;
56
56
  }
57
57
 
58
- /** SHA-256 of a buffer, hex-encoded. `node:crypto` only -- no package added (D-18). */
58
+ /** SHA-256 of a buffer, hex-encoded. `node:crypto` only -- no package added. */
59
59
  export function sha256Buffer(buf) {
60
60
  return createHash("sha256").update(buf).digest("hex");
61
61
  }
@@ -101,7 +101,7 @@ function charsetBase(d018Raw, dd00Raw) {
101
101
  // -------------------------------------------------------------- buildChipState
102
102
 
103
103
  /**
104
- * Build the D-04 chip-state sidecar in the exact shape the committed
104
+ * Build the chip-state sidecar in the exact shape the committed
105
105
  * primary sidecars already use (same top-level keys, same `derived` field
106
106
  * set), from the register/state readings the agent recorded. `raw` carries
107
107
  * whatever the agent fetched via vice_registers_get / vice_sprite_get /
@@ -163,7 +163,7 @@ function powerOnRunLength(image, start) {
163
163
  }
164
164
 
165
165
  /**
166
- * Emit the D-02 range manifest in the committed shape: ranges whose union
166
+ * Emit the range manifest in the committed shape: ranges whose union
167
167
  * covers $0000-$FFFF with no gap and no overlap, a contiguous power-on-
168
168
  * pattern run of at least 16 bytes marked kind `unused`, the I/O window
169
169
  * ($D000-$DFFF) marked `io`, everything else `unclassified`, and