@henols/c64-re-tools 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +61 -0
  2. package/bin/cli.mjs +226 -0
  3. package/package.json +53 -0
  4. package/skills/acme-build/SKILL.md +224 -0
  5. package/skills/acme-build/scripts/acme.mjs +263 -0
  6. package/skills/acme-build/template.a +39 -0
  7. package/skills/c64-memory-mapping/SKILL.md +199 -0
  8. package/skills/c64-memory-mapping/memmap.json +8800 -0
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
  10. package/skills/c64-program-recon/SKILL.md +172 -0
  11. package/skills/c64-program-recon/references/control-flow.md +174 -0
  12. package/skills/c64-program-recon/references/graphics.md +73 -0
  13. package/skills/c64-program-recon/references/observation-hazards.md +118 -0
  14. package/skills/c64-program-recon/references/reconstruction.md +128 -0
  15. package/skills/c64-program-recon/references/sound-and-input.md +68 -0
  16. package/skills/c64-program-recon/references/tool-selection.md +55 -0
  17. package/skills/c64-program-recon/scripts/derive.mjs +364 -0
  18. package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
  19. package/skills/c64-provenance-diff/SKILL.md +257 -0
  20. package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
  21. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
  23. package/skills/c64-ram-capture/SKILL.md +306 -0
  24. package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
  25. package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
  26. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
  28. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
  29. package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
  30. package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
  31. package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
  32. package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
  33. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
  34. package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
  35. package/skills/vice-wedge-triage/SKILL.md +149 -0
@@ -0,0 +1,981 @@
1
+ #!/usr/bin/env node
2
+ // The provenance diff (01-05): anchor-proven offset search, N-way byte diff,
3
+ // gap-tolerant range coalescing, patch counting, the loader/cracktro/game
4
+ // three-bucket partition, and the generated ledger tier. Every input here is
5
+ // an already-committed file (a release's primary `.bin` dump, its
6
+ // `.map.json` range manifest, and `recovery/RELEASES.json`) and every tool
7
+ // is pure Node over those files -- nothing in this module contacts the
8
+ // emulator, ever (D-18: zero third-party dependencies, `Buffer.indexOf` and
9
+ // `node:crypto` are sufficient).
10
+ //
11
+ // This is the step the objective calls "the one most able to produce
12
+ // confident nonsense": an un-normalised diff manufactures false
13
+ // CRACKER-PATCH verdicts wholesale, so every function below either proves
14
+ // its own precondition (proveOffset refuses a majority vote) or refuses to
15
+ // emit at all (renderLedger) rather than launder an assumption as evidence.
16
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { fileURLToPath } from "node:url";
19
+ import { dirname, join, resolve, relative } from "node:path";
20
+
21
+ import { loadRegistry, registryPath, upsertRelease } from "../../c64-ram-capture/scripts/releases.mjs";
22
+ import { addrNum, hex4 } from "../../c64-ram-capture/scripts/watch-loads.mjs";
23
+ import { projectRoot, dataRoot } from "../../c64-ram-capture/scripts/project-paths.mjs";
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const REPO_ROOT = projectRoot();
27
+ const RECOVERY_DIR = dataRoot();
28
+
29
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
30
+
31
+ function rel(p) {
32
+ return relative(REPO_ROOT, p);
33
+ }
34
+
35
+ function sha256Hex(bufOrStr) {
36
+ return createHash("sha256").update(bufOrStr).digest("hex");
37
+ }
38
+
39
+ // ------------------------------------------------------------------ ranges
40
+
41
+ /** Intersection of two `{start,end}` (inclusive) integer ranges, or null. */
42
+ function intersectRanges(a, b) {
43
+ const s = Math.max(a.start, b.start);
44
+ const e = Math.min(a.end, b.end);
45
+ return s <= e ? { start: s, end: e } : null;
46
+ }
47
+
48
+ /** `base` minus every range in `cuts` (any order/overlap), as a sorted list of remaining sub-ranges. */
49
+ function subtractRanges(base, cuts) {
50
+ let remaining = [{ start: base.start, end: base.end }];
51
+ for (const cut of cuts) {
52
+ const next = [];
53
+ for (const r of remaining) {
54
+ const inter = intersectRanges(r, cut);
55
+ if (!inter) { next.push(r); continue; }
56
+ if (r.start < inter.start) next.push({ start: r.start, end: inter.start - 1 });
57
+ if (r.end > inter.end) next.push({ start: inter.end + 1, end: r.end });
58
+ }
59
+ remaining = next;
60
+ }
61
+ return remaining.sort((a, b) => a.start - b.start);
62
+ }
63
+
64
+ // -------------------------------------------------------------- image I/O
65
+
66
+ function primaryDumpEntry(release) {
67
+ const dump = (release.dumps ?? []).find((d) => d.label === "run1");
68
+ if (!dump || !dump.bin) {
69
+ throw new Error(`primaryDumpEntry: release "${release.id}" has no run1 dump with a .bin recorded`);
70
+ }
71
+ return dump;
72
+ }
73
+
74
+ function readImage(binPath) {
75
+ const buf = readFileSync(join(REPO_ROOT, binPath));
76
+ if (buf.length !== 65536) {
77
+ throw new Error(`readImage: ${binPath} is ${buf.length} bytes, expected exactly 65536`);
78
+ }
79
+ return buf;
80
+ }
81
+
82
+ /** Every `dumps[]` entry's `range_manifest`, across every run label and every release -- enumerated from the registry, never hardcoded (see check-parameterisation / T-05 N-readiness). */
83
+ export function enumerateManifests(registry) {
84
+ const out = [];
85
+ for (const r of registry.releases) {
86
+ for (const d of r.dumps ?? []) {
87
+ if (d.range_manifest) out.push({ release: r.id, label: d.label, bin: d.bin, manifestPath: d.range_manifest });
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // ------------------------------------------------------------ anchorSearch
94
+
95
+ const VOLATILE_START = 0x0000;
96
+ const VOLATILE_END = 0x03ff; // CPU port regs, stack, KERNAL work area/BASIC input buffer -- see NOTES.md's own drift zones; biased away from as anchor source, never excluded from the diff itself.
97
+
98
+ function isTrivialRun(buf) {
99
+ const first = buf[0];
100
+ for (let i = 1; i < buf.length; i++) if (buf[i] !== first) return false;
101
+ return true; // a constant-byte run proves nothing and matches too easily
102
+ }
103
+
104
+ function findAllMatches(haystack, needle) {
105
+ const out = [];
106
+ let from = 0;
107
+ for (;;) {
108
+ const idx = haystack.indexOf(needle, from);
109
+ if (idx === -1) break;
110
+ out.push(idx);
111
+ from = idx + 1;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Select several long, distinctive byte runs from `source` (biased away from
118
+ * the volatile zero-page/stack/KERNAL-work-area region and away from
119
+ * constant-byte filler, per-run, not per-release -- a general C64 memory
120
+ * heuristic, not a release identifier), then locate each in `target` with
121
+ * `Buffer.indexOf`. For every anchor, reports its source offset, *all*
122
+ * match offsets found in target, the computed delta (matchOffset -
123
+ * sourceOffset, only when exactly one match was found), and the three bytes
124
+ * at target[matchOffset-1..matchOffset+1] -- the neighbour-byte check that
125
+ * makes an off-by-one visible rather than assumed.
126
+ */
127
+ export function anchorSearch(source, target, { minRunLength = 48, count = 8 } = {}) {
128
+ if (!Buffer.isBuffer(source) || !Buffer.isBuffer(target)) {
129
+ throw new Error("anchorSearch: source and target must be Buffers");
130
+ }
131
+ // Scan every offset (not a coarse stride) so a non-trivial run narrower
132
+ // than any sampling interval is never invisible to the search -- cheap
133
+ // even at 65536 bytes (O(length * minRunLength) byte comparisons).
134
+ const startFloor = Math.min(VOLATILE_END + 1, Math.max(0, source.length - minRunLength));
135
+ const candidateOffsets = [];
136
+ for (let offset = startFloor; offset + minRunLength <= source.length; offset++) {
137
+ if (!isTrivialRun(source.subarray(offset, offset + minRunLength))) {
138
+ candidateOffsets.push(offset);
139
+ }
140
+ }
141
+ // Spread the chosen anchors across the candidate list (preferring picks
142
+ // that are not immediately adjacent to one already chosen) rather than
143
+ // clustering at the front, so they sample different regions of the image.
144
+ const chosenOffsets = [];
145
+ const minGap = Math.max(1, Math.floor(source.length / (count * 2)));
146
+ const strideThroughCandidates = Math.max(1, Math.floor(candidateOffsets.length / count));
147
+ let lastChosen = -Infinity;
148
+ for (let i = 0; i < candidateOffsets.length && chosenOffsets.length < count; i += strideThroughCandidates) {
149
+ const c = candidateOffsets[i];
150
+ if (chosenOffsets.length === 0 || c - lastChosen >= minGap) {
151
+ chosenOffsets.push(c);
152
+ lastChosen = c;
153
+ }
154
+ }
155
+ if (chosenOffsets.length === 0 && candidateOffsets.length > 0) chosenOffsets.push(candidateOffsets[0]);
156
+ const chosen = chosenOffsets.map((sourceOffset) => ({
157
+ sourceOffset,
158
+ run: Buffer.from(source.subarray(sourceOffset, sourceOffset + minRunLength)),
159
+ }));
160
+
161
+ return chosen.map(({ sourceOffset, run }) => {
162
+ const matches = findAllMatches(target, run);
163
+ const unique = matches.length === 1;
164
+ const delta = unique ? matches[0] - sourceOffset : null;
165
+ const matchOffset = unique ? matches[0] : null;
166
+ const neighbourBytes = unique
167
+ ? {
168
+ before: matchOffset - 1 >= 0 ? target[matchOffset - 1] : null,
169
+ at: target[matchOffset],
170
+ after: matchOffset + 1 < target.length ? target[matchOffset + 1] : null,
171
+ }
172
+ : null;
173
+ return {
174
+ sourceOffset,
175
+ runLength: run.length,
176
+ runHex: run.toString("hex"),
177
+ matches,
178
+ matchCount: matches.length,
179
+ unique,
180
+ delta,
181
+ matchOffset,
182
+ neighbourBytes,
183
+ };
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Accepts a single global offset only when every *unique-match* anchor's
189
+ * delta agrees. An anchor matching at more than one target offset is
190
+ * rejected outright (a non-unique anchor proves nothing) and excluded from
191
+ * the agreement check. On disagreement among the remaining anchors, this
192
+ * FAILS -- naming the disagreeing anchors and their deltas -- rather than
193
+ * returning a majority answer, because a majority vote on a relocation
194
+ * offset is exactly the silent-plausible-wrongness this guards against.
195
+ */
196
+ export function proveOffset(anchorResults) {
197
+ const rejected = anchorResults.filter((a) => !a.unique);
198
+ const usable = anchorResults.filter((a) => a.unique);
199
+ if (usable.length === 0) {
200
+ return {
201
+ ok: false,
202
+ offset: null,
203
+ rejected,
204
+ usable: [],
205
+ disagreeing: [],
206
+ reason: `no anchor produced a unique match in the target image (${rejected.length} anchor(s) rejected as non-unique or unmatched) -- cannot prove any offset`,
207
+ };
208
+ }
209
+ const deltas = new Set(usable.map((a) => a.delta));
210
+ if (deltas.size === 1) {
211
+ return {
212
+ ok: true,
213
+ offset: usable[0].delta,
214
+ rejected,
215
+ usable,
216
+ disagreeing: [],
217
+ reason: `all ${usable.length} usable anchor(s) agree on offset ${usable[0].delta}`,
218
+ };
219
+ }
220
+ const byDelta = new Map();
221
+ for (const a of usable) {
222
+ if (!byDelta.has(a.delta)) byDelta.set(a.delta, []);
223
+ byDelta.get(a.delta).push(a);
224
+ }
225
+ return {
226
+ ok: false,
227
+ offset: null,
228
+ rejected,
229
+ usable: [],
230
+ disagreeing: usable,
231
+ byDelta: Object.fromEntries([...byDelta.entries()].map(([d, arr]) => [String(d), arr.map((a) => a.sourceOffset)])),
232
+ reason:
233
+ `anchors disagree on offset: ${[...byDelta.entries()].map(([d, arr]) => `delta=${d} (${arr.length} anchor(s): ${arr.map((a) => hex4(a.sourceOffset)).join(", ")})`).join("; ")}` +
234
+ " -- refusing a majority vote; fall back to per-region offsets recorded in the manifest",
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Exact integer arithmetic, never wrapped modulo 65536. An address whose
240
+ * offset-adjusted counterpart falls outside $0000-$FFFF is reported
241
+ * out-of-range with a named reason and excluded from any compared set.
242
+ */
243
+ export function applyOffset(address, offset) {
244
+ const addr = addrNum(address);
245
+ const target = addr + offset;
246
+ if (target < 0 || target > 0xffff) {
247
+ return {
248
+ address: addr,
249
+ offset,
250
+ target,
251
+ inRange: false,
252
+ reason: `offset-adjusted address (raw ${target}, 0x${target.toString(16)}) falls outside $0000-$FFFF -- excluded from the compared set, never wrapped modulo 65536`,
253
+ };
254
+ }
255
+ return { address: addr, offset, target, inRange: true };
256
+ }
257
+
258
+ // -------------------------------------------------------- provenance offset
259
+
260
+ function loadOffsets(registry) {
261
+ const out = {};
262
+ for (const r of registry.releases) {
263
+ const po = r.provenance_offset;
264
+ out[r.id] = po && typeof po.offset === "number" ? po.offset : 0;
265
+ }
266
+ return out;
267
+ }
268
+
269
+ function recordProvenanceOffset(releaseId, data) {
270
+ return upsertRelease(releaseId, (r) => ({ ...r, provenance_offset: data }));
271
+ }
272
+
273
+ // -------------------------------------------------------- cracktro scan
274
+
275
+ function isPrintableByte(b) {
276
+ return b >= 0x20 && b <= 0x7e;
277
+ }
278
+
279
+ /** Plain buffer scan for runs of printable-ASCII bytes. NOT by itself the cracktro bucket's seed -- see findCracktroRuns below. */
280
+ export function findPrintableRuns(buffer, { minLength = 8 } = {}) {
281
+ const runs = [];
282
+ let i = 0;
283
+ while (i < buffer.length) {
284
+ if (!isPrintableByte(buffer[i])) { i++; continue; }
285
+ let j = i;
286
+ while (j < buffer.length && isPrintableByte(buffer[j])) j++;
287
+ if (j - i >= minLength) runs.push({ start: i, end: j - 1 });
288
+ i = j;
289
+ }
290
+ return runs;
291
+ }
292
+
293
+ // A DEFAULT vocabulary of crack-scene credit phrasing. Deliberately generic
294
+ // words rather than any particular group's name or release id, so this stays
295
+ // vocabulary matching and never becomes an id comparison. Override it per call
296
+ // via `findCracktroRuns(buf, { signatures })` when a corpus uses different
297
+ // phrasing; a project should seed it from evidence it has actually verified.
298
+ //
299
+ // Why a vocabulary at all, instead of scanning for any printable run: a blind
300
+ // "any printable ASCII run" scan misclassifies a GAME'S OWN title-screen text
301
+ // as cracktro content. That is not hypothetical -- it was observed against a
302
+ // real two-release corpus, where the title text differed between releases and a
303
+ // bare scan called it cracker credit. A differing string is not a cracker
304
+ // string. Keep the bar at recognised credit vocabulary.
305
+ export const CRACKTRO_SIGNATURE_WORDS = ["CRACKED", "CRACKERS", "SOFT GROUP", "BREAK'EM", "MAKE'EM", "PRESENTS BY", "CRACKED BY"];
306
+
307
+ /**
308
+ * The actual seed for the cracktro bucket: printable-ASCII runs whose
309
+ * decoded text contains at least one recognised crack-credit vocabulary
310
+ * word. Narrower than `findPrintableRuns` on purpose -- see
311
+ * CRACKTRO_SIGNATURE_WORDS's comment for why a bare printable-run scan is
312
+ * not enough by itself.
313
+ */
314
+ export function findCracktroRuns(buffer, { minLength = 8, signatures = CRACKTRO_SIGNATURE_WORDS } = {}) {
315
+ const upperSignatures = signatures.map((s) => s.toUpperCase());
316
+ return findPrintableRuns(buffer, { minLength }).filter((r) => {
317
+ const text = buffer.subarray(r.start, r.end + 1).toString("latin1").toUpperCase();
318
+ return upperSignatures.some((sig) => text.includes(sig));
319
+ });
320
+ }
321
+
322
+ // ------------------------------------------------------------- diffRanges
323
+
324
+ // The four alternatives an UNKNOWN verdict must have ruled out before it is
325
+ // honest. Stated generically: each clause names the precondition the pipeline
326
+ // itself enforces, so the sentence is true for any corpus this runs against
327
+ // rather than describing one project's dumps.
328
+ const RULED_OUT_ALTERNATIVES =
329
+ "Alternatives checked and ruled out: not a revision difference (all releases were captured at the same " +
330
+ "recorded trigger, so they are the same build state); not a read error (each release's own multi-run " +
331
+ "reproducibility verdict passed before its primary dump was accepted); not a packer artifact (every image " +
332
+ "is captured post-load at that same fully-loaded trigger, which is the normalisation requirement); not " +
333
+ "relocation (the anchor-proven offset for this pair is recorded above and used here).";
334
+
335
+ /**
336
+ * N-way per-address comparison, aligned via each image's own anchor-proven
337
+ * offset, followed internally by `coalesceRanges` at `gapTolerance`. Returns
338
+ * ranges whose union covers $0000-$FFFF with no gap and no overlap; a range
339
+ * with fewer than two covering releases (including a range only one release
340
+ * covers) is UNKNOWN, never ORIGINAL. `images`: `[{ id, bytes, offset,
341
+ * loaderRanges, cracktroRuns }]`, all already read from the registry's
342
+ * primary dumps -- never a hardcoded pair.
343
+ */
344
+ export function diffRanges(images, { gapTolerance = 16 } = {}) {
345
+ if (!Array.isArray(images) || images.length === 0) {
346
+ throw new Error("diffRanges: images must be a non-empty array");
347
+ }
348
+ const raw = [];
349
+ for (let addr = 0; addr <= 0xffff; addr++) {
350
+ const available = [];
351
+ for (const img of images) {
352
+ const applied = applyOffset(addr, img.offset ?? 0);
353
+ if (applied.inRange) available.push({ id: img.id, value: img.bytes[applied.target], localAddr: applied.target });
354
+ }
355
+ let rec;
356
+ if (available.length < 2) {
357
+ rec = {
358
+ verdict: "UNKNOWN",
359
+ agreeing_releases: available.length,
360
+ evidence: "",
361
+ reason:
362
+ available.length === 0
363
+ ? "no release has in-range coverage at this address after offset application"
364
+ : `only one release ("${available[0].id}") covers this address -- cannot corroborate against an independent release`,
365
+ };
366
+ } else {
367
+ const allEqual = available.every((a) => a.value === available[0].value);
368
+ if (allEqual) {
369
+ // Deliberately does NOT quote the specific byte value: this record
370
+ // gets collapsed with its neighbours into a multi-address range
371
+ // (potentially spanning many different byte values, all agreeing
372
+ // internally at their own address), so an evidence string tied to
373
+ // one address's value would be both wrong for the range and would
374
+ // silently defeat collapsing (no two addresses would ever compare
375
+ // equal on evidence text, discovered live while running this tool
376
+ // against the real dumps -- see .planning/RE-FINDINGS.md).
377
+ rec = {
378
+ verdict: "ORIGINAL",
379
+ agreeing_releases: available.length,
380
+ evidence: `identical across ${available.length} independently-cracked releases (${available.map((a) => a.id).join(", ")}), at the anchor-proven offset`,
381
+ reason: "",
382
+ };
383
+ } else {
384
+ // Differing. Only two mechanically-detectable "recognised cracker
385
+ // techniques" are checked here: the address falls inside a
386
+ // release's own earned loader_ranges (loader replacement), or
387
+ // inside a printable-text run found by the cracktro scan (intro
388
+ // splice). Anything else is UNKNOWN with a reason -- this project
389
+ // never launders an unrecognised difference into a CRACKER-PATCH
390
+ // verdict (the prohibition this plan carries).
391
+ let technique = null;
392
+ let techniqueRelease = null;
393
+ for (const img of images) {
394
+ const applied = applyOffset(addr, img.offset ?? 0);
395
+ if (!applied.inRange) continue;
396
+ if ((img.loaderRanges ?? []).some((lr) => applied.target >= lr.start && applied.target <= lr.end)) {
397
+ technique = "loader replacement/relocation -- this address is inside a crack's own earned loader_ranges entry (each crack replaces the original loader with its own, per Pitfall 4)";
398
+ techniqueRelease = img.id;
399
+ break;
400
+ }
401
+ }
402
+ if (!technique) {
403
+ for (const img of images) {
404
+ const applied = applyOffset(addr, img.offset ?? 0);
405
+ if (!applied.inRange) continue;
406
+ if ((img.cracktroRuns ?? []).some((cr) => applied.target >= cr.start && applied.target <= cr.end)) {
407
+ technique = "intro/cracktro splice -- this address is inside a printable-text run found by the cracktro banner/credit scan (per Pitfall 4)";
408
+ techniqueRelease = img.id;
409
+ break;
410
+ }
411
+ }
412
+ }
413
+ if (technique) {
414
+ rec = {
415
+ verdict: "CRACKER-PATCH",
416
+ agreeing_releases: 0,
417
+ evidence: `${technique} (release "${techniqueRelease}"). ${RULED_OUT_ALTERNATIVES}`,
418
+ reason: "",
419
+ };
420
+ } else {
421
+ // Same reasoning as the ORIGINAL branch above: no per-address byte
422
+ // value is quoted, so this record can collapse with adjacent
423
+ // same-signature UNKNOWN records into one range.
424
+ rec = {
425
+ verdict: "UNKNOWN",
426
+ agreeing_releases: 0,
427
+ evidence: "",
428
+ reason: `differs across ${available.length} release(s) (${available.map((a) => a.id).join(", ")}) with no recognised cracker signature (not inside any release's loader_ranges or cracktro scan). ${RULED_OUT_ALTERNATIVES}`,
429
+ };
430
+ }
431
+ }
432
+ }
433
+ raw.push({ start: addr, end: addr, ...rec });
434
+ }
435
+ // Collapse into maximal contiguous same-signature ranges before coalescing.
436
+ const collapsed = [];
437
+ for (const r of raw) {
438
+ const prev = collapsed[collapsed.length - 1];
439
+ if (
440
+ prev &&
441
+ prev.end + 1 === r.start &&
442
+ prev.verdict === r.verdict &&
443
+ prev.agreeing_releases === r.agreeing_releases &&
444
+ prev.evidence === r.evidence &&
445
+ prev.reason === r.reason
446
+ ) {
447
+ prev.end = r.end;
448
+ } else {
449
+ collapsed.push({ ...r });
450
+ }
451
+ }
452
+ const { ranges, kept, coalesced } = coalesceRanges(collapsed, gapTolerance);
453
+ return { ranges, kept, coalesced, gapTolerance, imageIds: images.map((i) => i.id) };
454
+ }
455
+
456
+ // --------------------------------------------------------- coalesceRanges
457
+
458
+ /**
459
+ * Merges neighbouring non-ORIGINAL ("differing") ranges across a run of
460
+ * ORIGINAL bytes strictly shorter than `gapTolerance`; a run of exactly
461
+ * `gapTolerance` identical bytes is left as its own separate ORIGINAL row
462
+ * (the boundary is defined, not incidental). Reports kept-vs-coalesced
463
+ * counts in the same shape `acme.mjs`'s `curateLabels` already uses.
464
+ */
465
+ export function coalesceRanges(ranges, gapTolerance) {
466
+ if (!Array.isArray(ranges) || ranges.length === 0) return { ranges: [], kept: 0, coalesced: 0 };
467
+ if (!(gapTolerance >= 0)) throw new Error(`coalesceRanges: gapTolerance must be >= 0, got ${gapTolerance}`);
468
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
469
+ const isOriginal = (r) => r.verdict === "ORIGINAL";
470
+
471
+ const out = [];
472
+ let coalescedCount = 0;
473
+ let i = 0;
474
+ while (i < sorted.length) {
475
+ if (isOriginal(sorted[i])) {
476
+ out.push({ ...sorted[i] });
477
+ i++;
478
+ continue;
479
+ }
480
+ const group = [sorted[i]];
481
+ let j = i + 1;
482
+ for (;;) {
483
+ if (j >= sorted.length) break;
484
+ if (!isOriginal(sorted[j])) {
485
+ group.push(sorted[j]);
486
+ j++;
487
+ continue;
488
+ }
489
+ const gapLen = sorted[j].end - sorted[j].start + 1;
490
+ const nextIsDiffering = j + 1 < sorted.length && !isOriginal(sorted[j + 1]);
491
+ if (gapLen < gapTolerance && nextIsDiffering) {
492
+ group.push(sorted[j]); // swallow the short agreeing gap
493
+ j++;
494
+ continue;
495
+ }
496
+ break; // gap too long (>= tolerance), or nothing differing follows
497
+ }
498
+ out.push(mergeGroup(group));
499
+ coalescedCount += group.length - 1;
500
+ i = j;
501
+ }
502
+ return { ranges: out, kept: out.length, coalesced: coalescedCount };
503
+ }
504
+
505
+ function mergeGroup(group) {
506
+ if (group.length === 1) return { ...group[0] };
507
+ const start = group[0].start;
508
+ const end = group[group.length - 1].end;
509
+ const nonOriginal = group.filter((r) => r.verdict !== "ORIGINAL");
510
+ const verdictSet = new Set(nonOriginal.map((r) => r.verdict));
511
+ const mixed = verdictSet.size > 1;
512
+ const verdict = mixed ? "UNKNOWN" : [...verdictSet][0];
513
+ const agreeing = nonOriginal.length
514
+ ? Math.min(...nonOriginal.map((r) => (typeof r.agreeing_releases === "number" ? r.agreeing_releases : 0)))
515
+ : 0;
516
+ // Only the non-ORIGINAL constituents' text is worth surfacing here -- a
517
+ // swallowed ORIGINAL gap's own evidence ("identical across N releases...")
518
+ // is generic boilerplate that adds nothing once summarised by
519
+ // `swallowedGap` below. Deduplicated (via Set) so a coalesced range with
520
+ // many same-reason singleton addresses doesn't repeat identical
521
+ // boilerplate once per address -- found live while running this against
522
+ // the real dumps (see .planning/RE-FINDINGS.md).
523
+ const constituentNotes = [...new Set(nonOriginal.map((r) => r.evidence || r.reason).filter(Boolean))];
524
+ const swallowedGap = group.length > nonOriginal.length;
525
+ const note =
526
+ (mixed
527
+ ? `coalesced group of mixed verdicts (${[...verdictSet].join(", ")}) within the gap tolerance -- downgraded to UNKNOWN, the conservative choice. `
528
+ : "") +
529
+ (swallowedGap ? `Includes a short run of agreeing bytes swallowed by the gap tolerance. ` : "") +
530
+ (constituentNotes.length > 1
531
+ ? `Constituent findings (${constituentNotes.length} distinct): ${constituentNotes.join(" | ")}`
532
+ : `Constituent finding: ${constituentNotes[0] ?? ""}`);
533
+ return {
534
+ start,
535
+ end,
536
+ verdict,
537
+ agreeing_releases: agreeing,
538
+ evidence: verdict === "CRACKER-PATCH" ? note : "",
539
+ reason: verdict === "UNKNOWN" ? note : "",
540
+ coalesced_from: group.length,
541
+ };
542
+ }
543
+
544
+ // -------------------------------------------------------------- countPatches
545
+
546
+ /**
547
+ * Per release, the number of bytes verdicted CRACKER-PATCH that fall inside
548
+ * that release's own manifest ranges bucketed `game`. Deterministic and
549
+ * re-runnable: recomputed each time from the committed images, manifests
550
+ * and registry, never from ephemeral state.
551
+ */
552
+ export function countPatches(images, diffResult, bucketedManifestsByRelease) {
553
+ const counts = {};
554
+ for (const img of images) counts[img.id] = 0;
555
+ for (const range of diffResult.ranges) {
556
+ if (range.verdict !== "CRACKER-PATCH") continue;
557
+ for (let addr = range.start; addr <= range.end; addr++) {
558
+ for (const img of images) {
559
+ const applied = applyOffset(addr, img.offset ?? 0);
560
+ if (!applied.inRange) continue;
561
+ const manifest = bucketedManifestsByRelease[img.id];
562
+ if (!manifest) continue;
563
+ const kind = lookupKind(manifest.ranges, applied.target);
564
+ if (kind === "game") counts[img.id] += 1;
565
+ }
566
+ }
567
+ }
568
+ return counts;
569
+ }
570
+
571
+ /** Linear scan for the range containing `address` in a sorted, gapless, non-overlapping ranges array. */
572
+ function lookupKind(sortedRanges, address) {
573
+ for (const r of sortedRanges) {
574
+ if (address >= r.start && address <= r.end) return r.kind;
575
+ }
576
+ return null;
577
+ }
578
+
579
+ /**
580
+ * Splits each diff range against a manifest's own kind boundaries, so the
581
+ * ledger's `kind` column is never resolved from only a range's start
582
+ * address -- a coalesced range can span multiple kind zones (e.g. `game`
583
+ * then `loader`) since coalescing groups on VERDICT continuity, not kind
584
+ * continuity. Resolving kind from `start` alone silently mislabels every
585
+ * address after the first kind boundary inside the range; found live
586
+ * against a real corpus (a wide ORIGINAL range was found spannings
587
+ * straight through its own $0340-$035E `loader` sub-range).
588
+ */
589
+ export function splitRangeByManifestKind(range, manifestRanges) {
590
+ const out = [];
591
+ for (const m of manifestRanges) {
592
+ const inter = intersectRanges(range, m);
593
+ if (inter) out.push({ ...range, start: inter.start, end: inter.end, kind: m.kind });
594
+ }
595
+ return out.sort((a, b) => a.start - b.start);
596
+ }
597
+
598
+ // --------------------------------------------------------- manifest bucketing
599
+
600
+ /**
601
+ * Promote one manifest from `ranges-only` to `bucketed`: `unused`/`io`
602
+ * ranges are kept verbatim (D-02's byte-level classification already
603
+ * stands); every `unclassified` range is re-partitioned against the
604
+ * release's earned `loader_ranges` (never NOTES.md prose) and this image's
605
+ * own cracktro printable-run scan, with the remainder -- reached by the
606
+ * trace/entry point -- bucketed `game`. Per D-05 the underlying bytes are
607
+ * never edited; only the manifest's own `kind` field changes.
608
+ */
609
+ export function bucketManifest(image, manifest, { loaderRanges, cracktroMinLength = 8 } = {}) {
610
+ const cracktroRuns = findCracktroRuns(image, { minLength: cracktroMinLength });
611
+ const loaderNumeric = loaderRanges.map((lr) => ({
612
+ start: addrNum(lr.start),
613
+ end: addrNum(lr.end),
614
+ note: lr.note ?? "",
615
+ evidence: lr.evidence ?? "",
616
+ }));
617
+ // Keep every already-classified range verbatim (unused/io from D-02's
618
+ // byte-level pass, or -- on a re-run of an already-bucketed manifest --
619
+ // game/loader/cracktro from a prior run of this same function). Only
620
+ // "unclassified" is ever re-partitioned. Filtering "kept" down to just
621
+ // unused/io would silently discard game/loader/cracktro ranges on a
622
+ // second run, since nothing would remain to reclassify them from -- an
623
+ // idempotency bug caught before it ever reached a committed manifest.
624
+ const kept = manifest.ranges.filter((r) => r.kind !== "unclassified");
625
+ const toBucket = manifest.ranges.filter((r) => r.kind === "unclassified");
626
+ const newRanges = kept.map((r) => ({ ...r }));
627
+
628
+ for (const u of toBucket) {
629
+ const loaderCuts = loaderNumeric.map((lr) => intersectRanges(u, lr)).filter(Boolean);
630
+ const cracktroCuts = cracktroRuns.map((cr) => intersectRanges(u, cr)).filter(Boolean);
631
+ for (const c of loaderCuts) {
632
+ const src = loaderNumeric.find((lr) => c.start >= lr.start && c.end <= lr.end);
633
+ newRanges.push({
634
+ start: c.start,
635
+ end: c.end,
636
+ kind: "loader",
637
+ source: "diff-images:loader_ranges",
638
+ note: `seeded from recovery/RELEASES.json's earned loader_ranges (live disassembly evidence, never NOTES.md prose): ${src?.note ?? ""}`,
639
+ });
640
+ }
641
+ for (const c of cracktroCuts) {
642
+ newRanges.push({
643
+ start: c.start,
644
+ end: c.end,
645
+ kind: "cracktro",
646
+ source: "diff-images:printable-scan",
647
+ note: `printable-byte run of length ${c.end - c.start + 1} found by a plain buffer scan for banner/credit text`,
648
+ });
649
+ }
650
+ const allCuts = [...loaderCuts, ...cracktroCuts].sort((a, b) => a.start - b.start);
651
+ const remainder = subtractRanges(u, allCuts);
652
+ for (const r of remainder) {
653
+ newRanges.push({
654
+ start: r.start,
655
+ end: r.end,
656
+ kind: "game",
657
+ source: "diff-images:trace-remainder",
658
+ note: "reached by the trace/entry point; not classified loader, cracktro, io, or unused",
659
+ });
660
+ }
661
+ }
662
+
663
+ newRanges.sort((a, b) => a.start - b.start || a.end - b.end);
664
+ return { ...manifest, classification_state: "bucketed", ranges: newRanges };
665
+ }
666
+
667
+ // ------------------------------------------------------------- renderLedger
668
+
669
+ const D02_KINDS = new Set(["game", "loader", "cracktro", "io", "unused"]);
670
+
671
+ /**
672
+ * Renders `recovery/PROVENANCE.md`'s two tiers. Refuses to emit at all
673
+ * (throws, no file written) if any row is UNKNOWN with an empty reason, any
674
+ * row is ORIGINAL with an agreeing-release count below two, or the rows do
675
+ * not cover exactly $0000-$FFFF with no gap and no overlap.
676
+ */
677
+ export function renderLedger({ generatedRanges, gapTolerance, prose }) {
678
+ const sorted = [...generatedRanges].sort((a, b) => a.start - b.start || a.end - b.end);
679
+ let expected = 0;
680
+ for (const r of sorted) {
681
+ if (r.verdict === "UNKNOWN" && !(r.reason && r.reason.trim())) {
682
+ throw new Error(`renderLedger: refusing to emit -- range ${hex4(r.start)}-${hex4(r.end)} has verdict UNKNOWN with an empty reason`);
683
+ }
684
+ if (r.verdict === "ORIGINAL" && !((r.agreeing_releases ?? 0) >= 2)) {
685
+ throw new Error(`renderLedger: refusing to emit -- range ${hex4(r.start)}-${hex4(r.end)} has verdict ORIGINAL with agreeing_releases=${r.agreeing_releases} (< 2)`);
686
+ }
687
+ if (r.start !== expected) {
688
+ throw new Error(`renderLedger: refusing to emit -- gap or overlap in the generated tier at ${hex4(expected)} (next range starts at ${hex4(r.start)})`);
689
+ }
690
+ expected = r.end + 1;
691
+ }
692
+ if (expected !== 0x10000) {
693
+ throw new Error(`renderLedger: refusing to emit -- generated tier stops at ${hex4(expected - 1)}, does not reach $FFFF`);
694
+ }
695
+
696
+ let generated = `<!-- GENERATED, DO NOT HAND-EDIT. Regenerate with: node .claude/skills/c64-provenance-diff/scripts/diff-images.mjs ledger --gap-tolerance ${gapTolerance} -->\n\n`;
697
+ generated += `| Start | End | Kind | Verdict | Confidence | Agreeing releases | Evidence / Reason |\n`;
698
+ generated += `|---|---|---|---|---|---|---|\n`;
699
+ for (const r of sorted) {
700
+ const confidence =
701
+ r.verdict === "ORIGINAL" ? (r.agreeing_releases >= 3 ? "HIGH" : "MEDIUM-HIGH") :
702
+ r.verdict === "CRACKER-PATCH" ? "HIGH (patch), MEDIUM-LOW (what original there replaced)" :
703
+ "LOW";
704
+ const kind = D02_KINDS.has(r.kind) ? r.kind : (r.kind ?? "unresolved");
705
+ const text = (r.evidence || r.reason || "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
706
+ generated += `| ${hex4(r.start)} | ${hex4(r.end)} | ${kind} | ${r.verdict} | ${confidence} | ${r.agreeing_releases} | ${text} |\n`;
707
+ }
708
+
709
+ const header = `# \`recovery/PROVENANCE.md\` -- the provenance ledger\n\n` +
710
+ `Two tiers, one direction of truth. The **generated tier** below is machine-produced by ` +
711
+ `\`.claude/skills/c64-provenance-diff/scripts/diff-images.mjs\`'s \`renderLedger\` and is regenerable at any time from the committed ` +
712
+ `dumps plus the recorded offset -- never hand-edit it. The **prose tier** underneath states the ` +
713
+ `facts a table cannot hold. This file is the ledger; \`docs/provenance.md\` will be a summary ` +
714
+ `pointer and inline \`; PROVENANCE:\` tags in \`src/\` will be the point-of-use copy -- one ` +
715
+ `direction only, and no downstream copy is ever edited independently (per ARCHITECTURE.md).\n\n`;
716
+
717
+ return header + `## Generated tier\n\n` + generated + `\n## Prose tier\n\n` + prose;
718
+ }
719
+
720
+ // ------------------------------------------------------------------- helpers
721
+
722
+ function loadImagesForDiff(registry) {
723
+ const offsets = loadOffsets(registry);
724
+ return registry.releases.map((r) => {
725
+ const dump = primaryDumpEntry(r);
726
+ const bytes = readImage(dump.bin);
727
+ const loaderRanges = (r.loader_ranges ?? []).map((lr) => ({ start: addrNum(lr.start), end: addrNum(lr.end), note: lr.note, evidence: lr.evidence }));
728
+ const cracktroRuns = findCracktroRuns(bytes, { minLength: 8 });
729
+ return { id: r.id, bytes, offset: offsets[r.id] ?? 0, loaderRanges, cracktroRuns };
730
+ });
731
+ }
732
+
733
+ function readManifest(manifestPath) {
734
+ return JSON.parse(readFileSync(join(REPO_ROOT, manifestPath), "utf8"));
735
+ }
736
+
737
+ function writeManifest(manifestPath, manifest) {
738
+ writeFileSync(join(REPO_ROOT, manifestPath), JSON.stringify(manifest, null, 2) + "\n");
739
+ }
740
+
741
+ // -------------------------------------------------------------------- CLI
742
+
743
+ function optValue(rest, name) {
744
+ const i = rest.indexOf(`--${name}`);
745
+ return i === -1 ? undefined : rest[i + 1];
746
+ }
747
+
748
+ const VERBS = {
749
+ "anchor-search"(rest) {
750
+ const reg = loadRegistry();
751
+ if (reg.releases.length < 2) die("anchor-search needs at least two releases in the registry");
752
+ const referenceId = optValue(rest, "reference") ?? reg.releases[0].id;
753
+ const reference = reg.releases.find((r) => r.id === referenceId);
754
+ if (!reference) die(`unknown reference release "${referenceId}"`);
755
+ const refBytes = readImage(primaryDumpEntry(reference).bin);
756
+
757
+ const provenAt = new Date().toISOString();
758
+ // The reference release carries its own provenance_offset record too --
759
+ // required so every release in the registry has the same field set
760
+ // (recovery-schema.mjs's runBaseChecks asserts this).
761
+ recordProvenanceOffset(referenceId, {
762
+ role: "reference",
763
+ reference_release: null,
764
+ offset: 0,
765
+ anchor_count: null,
766
+ anchors_agreeing: null,
767
+ proven_at: provenAt,
768
+ method: "reference release -- every other release's offset is proven against this one's primary dump",
769
+ });
770
+
771
+ const results = {};
772
+ for (const r of reg.releases) {
773
+ if (r.id === referenceId) continue;
774
+ const targetBytes = readImage(primaryDumpEntry(r).bin);
775
+ const anchors = anchorSearch(refBytes, targetBytes);
776
+ const proof = proveOffset(anchors);
777
+ results[r.id] = { anchors, proof };
778
+ if (proof.ok) {
779
+ recordProvenanceOffset(r.id, {
780
+ role: "target",
781
+ reference_release: referenceId,
782
+ offset: proof.offset,
783
+ anchor_count: anchors.length,
784
+ anchors_agreeing: proof.usable.length,
785
+ proven_at: provenAt,
786
+ method: "anchor-proven via .claude/skills/c64-provenance-diff/scripts/diff-images.mjs anchor-search -- see NOTES.md for the full narrative",
787
+ });
788
+ }
789
+ }
790
+
791
+ if (rest.includes("--json")) {
792
+ console.log(JSON.stringify({ reference: referenceId, results }, null, 2));
793
+ } else {
794
+ for (const [id, { proof }] of Object.entries(results)) {
795
+ console.log(`${referenceId} -> ${id}: ok=${proof.ok} offset=${proof.offset} (${proof.reason})`);
796
+ }
797
+ }
798
+ process.exitCode = Object.values(results).every((r) => r.proof.ok) ? 0 : 1;
799
+ },
800
+
801
+ diff(rest) {
802
+ const gapTolerance = rest.includes("--gap-tolerance") ? Number(optValue(rest, "gap-tolerance")) : 16;
803
+ const reg = loadRegistry();
804
+ const images = loadImagesForDiff(reg);
805
+ const result = diffRanges(images, { gapTolerance });
806
+ if (rest.includes("--json")) {
807
+ console.log(JSON.stringify(result, null, 2));
808
+ } else {
809
+ console.log(`diff: ${result.ranges.length} range(s), gap_tolerance=${gapTolerance}, coalesced=${result.coalesced}`);
810
+ }
811
+
812
+ // Write back the three-bucket partition for every dumps[] entry in the
813
+ // registry, enumerated -- never a hardcoded pair.
814
+ for (const { release: releaseId, bin, manifestPath } of enumerateManifests(reg)) {
815
+ const releaseEntry = reg.releases.find((r) => r.id === releaseId);
816
+ const image = readImage(bin);
817
+ const manifest = readManifest(manifestPath);
818
+ const loaderRanges = releaseEntry.loader_ranges ?? [];
819
+ const bucketed = bucketManifest(image, manifest, { loaderRanges });
820
+ writeManifest(manifestPath, bucketed);
821
+ }
822
+ },
823
+
824
+ "count-patches"(rest) {
825
+ const gapTolerance = rest.includes("--gap-tolerance") ? Number(optValue(rest, "gap-tolerance")) : 16;
826
+ const reg = loadRegistry();
827
+ const images = loadImagesForDiff(reg);
828
+ const diffResult = diffRanges(images, { gapTolerance });
829
+ const bucketedManifestsByRelease = {};
830
+ for (const r of reg.releases) {
831
+ const dump = primaryDumpEntry(r);
832
+ bucketedManifestsByRelease[r.id] = readManifest(dump.range_manifest);
833
+ }
834
+ const counts = countPatches(images, diffResult, bucketedManifestsByRelease);
835
+ if (rest.includes("--json")) {
836
+ console.log(JSON.stringify({ counts }, null, 2));
837
+ } else {
838
+ for (const [id, n] of Object.entries(counts)) console.log(`${id}: ${n}`);
839
+ }
840
+ },
841
+
842
+ ledger(rest) {
843
+ const gapTolerance = rest.includes("--gap-tolerance") ? Number(optValue(rest, "gap-tolerance")) : 16;
844
+ const reg = loadRegistry();
845
+ const images = loadImagesForDiff(reg);
846
+ const diffResult = diffRanges(images, { gapTolerance });
847
+
848
+ const referenceId = reg.releases[0].id;
849
+ const referenceEntry = reg.releases.find((r) => r.id === referenceId);
850
+ const referenceManifest = readManifest(primaryDumpEntry(referenceEntry).range_manifest);
851
+ // Split every diff range against the reference manifest's own kind
852
+ // boundaries -- never resolve kind from a range's start address alone
853
+ // (see splitRangeByManifestKind's own comment for the real bug this
854
+ // fixes).
855
+ const generatedRanges = diffResult.ranges.flatMap((r) => splitRangeByManifestKind(r, referenceManifest.ranges));
856
+
857
+ // Project narrative is read from a file the project owns, never hardcoded
858
+ // here -- that is what lets this module run against someone else's corpus.
859
+ const prosePath = resolve(optValue(rest, "prose") ?? defaultProsePath());
860
+ let projectProse = null;
861
+ if (existsSync(prosePath)) {
862
+ projectProse = readFileSync(prosePath, "utf8")
863
+ .replace(/^<!--[\s\S]*?-->\s*/, "") // drop the file's own maintainer header
864
+ .replace(/\{\{gapTolerance\}\}/g, String(gapTolerance))
865
+ .trim();
866
+ } else {
867
+ console.error(
868
+ `ledger: no project prose at ${rel(prosePath)} -- emitting the derived prose only. ` +
869
+ `Create that file (or pass --prose <path>) to add project-specific narrative.`,
870
+ );
871
+ }
872
+
873
+ const prose = buildProse({ reg, images, gapTolerance, referenceId, projectProse });
874
+ let markdown;
875
+ try {
876
+ markdown = renderLedger({ generatedRanges, gapTolerance, prose });
877
+ } catch (e) {
878
+ console.error(`ledger: ${e.message}`);
879
+ process.exitCode = 1;
880
+ return;
881
+ }
882
+ const outPath = join(RECOVERY_DIR, "PROVENANCE.md");
883
+ writeFileSync(outPath, markdown);
884
+
885
+ // Record the generated tier's digest so a later phase can detect drift.
886
+ const generatedTierText = markdown.split("## Prose tier")[0];
887
+ const digest = sha256Hex(generatedTierText);
888
+ const rawReg = JSON.parse(readFileSync(registryPath, "utf8"));
889
+ rawReg.ledger = { generated_tier_sha256: digest, gap_tolerance: gapTolerance, generated_at: new Date().toISOString() };
890
+ writeFileSync(registryPath, JSON.stringify(rawReg, null, 2) + "\n");
891
+
892
+ console.log(`wrote ${rel(outPath)} (generated tier sha256 ${digest})`);
893
+ },
894
+ };
895
+
896
+ /** Where the hand-maintained project narrative lives. Overridable per run. */
897
+ export function defaultProsePath() {
898
+ return join(dataRoot(), "PROVENANCE.prose.md");
899
+ }
900
+
901
+ /**
902
+ * The prose tier, in two parts. Everything this function generates is derived
903
+ * from the registry and is true of ANY project using the tool -- the method, the
904
+ * per-release offsets, the coalescing mechanics, the seeding rules. Anything
905
+ * specific to one project's evidence (decision numbers, particular addresses,
906
+ * coverage caveats) belongs in the project's own prose file, which is appended
907
+ * verbatim. Keeping the two apart is what lets this module ship to another
908
+ * project without carrying someone else's findings.
909
+ */
910
+ function buildProse({ reg, images, gapTolerance, referenceId, projectProse }) {
911
+ const registryName = relative(projectRoot(), registryPath);
912
+
913
+ const offsetLines = images
914
+ .map((img) => {
915
+ const entry = reg.releases.find((r) => r.id === img.id);
916
+ const po = entry.provenance_offset;
917
+ if (img.id === referenceId) {
918
+ return `- **${img.id}** (reference release): offset 0 by definition -- every other release's offset is proven against this one's primary dump.`;
919
+ }
920
+ if (!po) {
921
+ return `- **${img.id}**: no provenance_offset recorded yet -- run the \`anchor-search\` verb first.`;
922
+ }
923
+ return `- **${img.id}**: proven offset **${po.offset}**, from ${po.anchor_count} anchor(s), all agreeing. Machine record in \`${registryName}\`'s \`provenance_offset\` field. Proven ${po.proven_at}.`;
924
+ })
925
+ .join("\n");
926
+
927
+ const dumpTriggerLines = reg.releases
928
+ .map((r) => `- **${r.id}**: dump trigger \`${r.trigger?.address ?? "unrecorded"}\` (\`${r.trigger?.kind ?? "unrecorded"}\`). All releases' captures were taken at this same trigger, so the images are directly comparable.`)
929
+ .join("\n");
930
+
931
+ const method =
932
+ `### The offset used, and how it was proven
933
+
934
+ The diff above runs at an **anchor-proven** offset per release, never an assumed one. Long,
935
+ distinctive byte runs were selected from the reference release's primary dump, located in each other
936
+ release's primary dump with \`Buffer.indexOf\`, and a global offset was accepted only when **every**
937
+ usable anchor's computed delta agreed -- a majority is refused. The neighbour bytes at each anchor's
938
+ resolved position (one before, at, and one after) were inspected so an off-by-one would be visible
939
+ rather than assumed.
940
+
941
+ ${offsetLines}
942
+
943
+ ### The state the images were normalised to
944
+
945
+ ${dumpTriggerLines}
946
+
947
+ ### The gap-coalescing tolerance
948
+
949
+ **${gapTolerance} identical bytes**, passed as \`--gap-tolerance ${gapTolerance}\`. Two differing
950
+ ranges separated by a run of identical (ORIGINAL-verdict) bytes *strictly shorter* than this
951
+ tolerance are coalesced into one row; a run of *exactly* ${gapTolerance} identical bytes is left as
952
+ its own separate row.
953
+
954
+ ### How the kinds were seeded
955
+
956
+ - **\`loader\`** comes from each release's own earned \`loader_ranges\` in \`${registryName}\`, which
957
+ should be live disassembly evidence -- never prose. A loader range read out of prose is how a
958
+ legitimate game instruction gets misclassified as loader code.
959
+ - **\`cracktro\`** comes from printable-ASCII runs whose decoded text contains a recognised
960
+ crack-credit vocabulary word, not from a bare "any printable run" scan. A bare scan misclassifies
961
+ a game's own title-screen text as cracker credit.
962
+ - **\`io\`** and **\`unused\`** were assigned at capture time and are kept verbatim.
963
+ - Everything else the trace and the entry point reach is bucketed **\`game\`**.
964
+
965
+ The underlying \`.bin\` files are never edited or zeroed: classification lives in this ledger and in
966
+ the manifests, and the bytes stay verbatim evidence.
967
+ `;
968
+
969
+ return projectProse ? `${method}\n${projectProse}` : method;
970
+ }
971
+
972
+
973
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
974
+ const [cmd, ...rest] = process.argv.slice(2);
975
+ if (!cmd || !VERBS[cmd]) {
976
+ console.log(`usage: node ${fileURLToPath(import.meta.url)} <anchor-search|diff|count-patches|ledger> [--gap-tolerance N] [--reference <id>] [--json]`);
977
+ process.exitCode = cmd ? 1 : 0;
978
+ } else {
979
+ VERBS[cmd](rest);
980
+ }
981
+ }