@henols/c64-re-tools 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/bin/cli.mjs +226 -0
- package/package.json +53 -0
- package/skills/acme-build/SKILL.md +224 -0
- package/skills/acme-build/scripts/acme.mjs +263 -0
- package/skills/acme-build/template.a +39 -0
- package/skills/c64-memory-mapping/SKILL.md +199 -0
- package/skills/c64-memory-mapping/memmap.json +8800 -0
- package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
- package/skills/c64-program-recon/SKILL.md +172 -0
- package/skills/c64-program-recon/references/control-flow.md +174 -0
- package/skills/c64-program-recon/references/graphics.md +73 -0
- package/skills/c64-program-recon/references/observation-hazards.md +118 -0
- package/skills/c64-program-recon/references/reconstruction.md +128 -0
- package/skills/c64-program-recon/references/sound-and-input.md +68 -0
- package/skills/c64-program-recon/references/tool-selection.md +55 -0
- package/skills/c64-program-recon/scripts/derive.mjs +364 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
- package/skills/c64-provenance-diff/SKILL.md +257 -0
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
- package/skills/c64-ram-capture/SKILL.md +306 -0
- package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
- package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
- package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
- package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
- package/skills/vice-wedge-triage/SKILL.md +149 -0
|
@@ -0,0 +1,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
|
+
}
|