@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,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compare 65536-byte C64 RAM captures and classify every difference.
|
|
3
|
+
//
|
|
4
|
+
// Pure logic. This module reads files the agent already captured and does
|
|
5
|
+
// arithmetic over them. It contacts nothing: the mcp__plugin_c64-re-tools_vice__* tools are the
|
|
6
|
+
// only route to the emulator (.claude/CLAUDE.md § Emulator Access), and
|
|
7
|
+
// nothing here opens a connection, reads broker state, or shells out.
|
|
8
|
+
//
|
|
9
|
+
// The classification rules are the ones c64-ram-capture/SKILL.md states, and
|
|
10
|
+
// they live here so they are applied identically every time instead of being
|
|
11
|
+
// re-derived by hand per session:
|
|
12
|
+
//
|
|
13
|
+
// volatile $0000-$0001, $0100-$01FF, $0200-$03FF, $D000-$DFFF -- counted,
|
|
14
|
+
// reported, excluded from the verdict
|
|
15
|
+
// drift exactly one bit differs -- listed as a candidate, does not fail
|
|
16
|
+
// divergence two or more bits differ -- listed, and fails the comparison
|
|
17
|
+
//
|
|
18
|
+
// $D000-$DFFF is this module's one departure from what SKILL.md said when it was
|
|
19
|
+
// written: that range is I/O, not RAM, so it can never be stable. See the VOLATILE
|
|
20
|
+
// table below and .planning/RE-FINDINGS.md (2026-08-04) for the evidence.
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { basename } from "node:path";
|
|
25
|
+
|
|
26
|
+
const IMAGE_BYTES = 65536;
|
|
27
|
+
|
|
28
|
+
// Volatile spans, inclusive. A difference inside these is expected on any two
|
|
29
|
+
// captures of the same checkpoint and never fails a comparison.
|
|
30
|
+
const VOLATILE = [
|
|
31
|
+
[0x0000, 0x0001], // CPU port
|
|
32
|
+
[0x0100, 0x01ff], // stack page
|
|
33
|
+
[0x0200, 0x03ff], // KERNAL work area / BASIC input buffer
|
|
34
|
+
// $D000-$DFFF is I/O, not RAM: the VIC's registers repeat every $40 across
|
|
35
|
+
// $D000-$D3FF and the SID's across $D400-$D7FF, so reading this range samples
|
|
36
|
+
// live hardware and two captures can never agree here. Added 2026-08-04 after
|
|
37
|
+
// every divergence across all six committed gameentry pairings landed either
|
|
38
|
+
// here ($D344, $D625, $D628) or in RAM under KERNAL ROM -- see
|
|
39
|
+
// .planning/RE-FINDINGS.md, 2026-08-04. Confidence HIGH: structural.
|
|
40
|
+
[0xd000, 0xdfff],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Deliberately NOT volatile: $E000-$FFFF (RAM under KERNAL ROM when HIRAM=0).
|
|
44
|
+
// $FAD8 and $FC51 do differ across captures, but only 2 addresses out of 8192 --
|
|
45
|
+
// far too few for power-on garbage, and unexplained. Blanket-excluding 8 KB on
|
|
46
|
+
// two data points would hide real divergence, so these still fail and the
|
|
47
|
+
// capture record carries the explanation. Confidence MEDIUM, see the same entry.
|
|
48
|
+
|
|
49
|
+
const isVolatile = (a) => VOLATILE.some(([lo, hi]) => a >= lo && a <= hi);
|
|
50
|
+
|
|
51
|
+
const hex4 = (n) => "$" + n.toString(16).toUpperCase().padStart(4, "0");
|
|
52
|
+
const hex2 = (n) => "$" + n.toString(16).toUpperCase().padStart(2, "0");
|
|
53
|
+
const bin8 = (n) => "%" + n.toString(2).padStart(8, "0");
|
|
54
|
+
|
|
55
|
+
const popcount = (n) => {
|
|
56
|
+
let c = 0;
|
|
57
|
+
while (n) {
|
|
58
|
+
n &= n - 1;
|
|
59
|
+
c++;
|
|
60
|
+
}
|
|
61
|
+
return c;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function loadImage(path) {
|
|
65
|
+
const buf = readFileSync(path);
|
|
66
|
+
if (buf.length !== IMAGE_BYTES) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${path}: ${buf.length} bytes, expected ${IMAGE_BYTES} — not a full 64K image`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return buf;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify every differing address between two images.
|
|
78
|
+
* Returns { volatile[], drift[], divergence[], pass } — the three lists
|
|
79
|
+
* SKILL.md requires, plus the verdict.
|
|
80
|
+
*/
|
|
81
|
+
function compare(a, b) {
|
|
82
|
+
const volatile_ = [];
|
|
83
|
+
const drift = [];
|
|
84
|
+
const divergence = [];
|
|
85
|
+
|
|
86
|
+
for (let addr = 0; addr < IMAGE_BYTES; addr++) {
|
|
87
|
+
const x = a[addr];
|
|
88
|
+
const y = b[addr];
|
|
89
|
+
if (x === y) continue;
|
|
90
|
+
|
|
91
|
+
const bits = popcount(x ^ y);
|
|
92
|
+
const rec = { addr, a: x, b: y, bits };
|
|
93
|
+
|
|
94
|
+
// Volatile wins over bit-count: an address in a volatile span is excluded
|
|
95
|
+
// from the verdict regardless of how many bits moved.
|
|
96
|
+
if (isVolatile(addr)) volatile_.push(rec);
|
|
97
|
+
else if (bits === 1) drift.push(rec);
|
|
98
|
+
else divergence.push(rec);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { volatile: volatile_, drift, divergence, pass: divergence.length === 0 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const fmtRow = (r) =>
|
|
105
|
+
` ${hex4(r.addr)} ${hex2(r.a)} ${bin8(r.a)} -> ${hex2(r.b)} ${bin8(r.b)} ${r.bits} bit${r.bits === 1 ? "" : "s"}`;
|
|
106
|
+
|
|
107
|
+
function printList(title, rows, limit) {
|
|
108
|
+
console.log(`\n${title}: ${rows.length}`);
|
|
109
|
+
if (!rows.length) return;
|
|
110
|
+
// --limit 0 means unlimited, matching the usage text. Anything else caps.
|
|
111
|
+
const shown = limit ? rows.slice(0, limit) : rows;
|
|
112
|
+
for (const r of shown) console.log(fmtRow(r));
|
|
113
|
+
if (shown.length < rows.length) {
|
|
114
|
+
console.log(` … ${rows.length - shown.length} more (--limit 0 for all)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cmdCompare(argv) {
|
|
119
|
+
const limit = limitFrom(argv);
|
|
120
|
+
const paths = argv.filter((s) => !s.startsWith("--") && !/^\d+$/.test(s));
|
|
121
|
+
if (paths.length !== 2) throw new Error("compare needs exactly two image paths");
|
|
122
|
+
|
|
123
|
+
const [pa, pb] = paths;
|
|
124
|
+
const a = loadImage(pa);
|
|
125
|
+
const b = loadImage(pb);
|
|
126
|
+
|
|
127
|
+
const ha = sha256(a);
|
|
128
|
+
const hb = sha256(b);
|
|
129
|
+
|
|
130
|
+
console.log(`A ${basename(pa)} sha256 ${ha}`);
|
|
131
|
+
console.log(`B ${basename(pb)} sha256 ${hb}`);
|
|
132
|
+
|
|
133
|
+
if (ha === hb) {
|
|
134
|
+
console.log("\nIDENTICAL — the two images are byte-for-byte equal.");
|
|
135
|
+
console.log("\nVERDICT: PASS");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const r = compare(a, b);
|
|
140
|
+
|
|
141
|
+
printList("volatile (excluded from the verdict)", r.volatile, limit);
|
|
142
|
+
printList("drift — exactly one bit, reported as candidates", r.drift, limit);
|
|
143
|
+
printList("DIVERGENCE — two or more bits, fails the comparison", r.divergence, limit);
|
|
144
|
+
|
|
145
|
+
const total = r.volatile.length + r.drift.length + r.divergence.length;
|
|
146
|
+
console.log(`\ntotal differing addresses: ${total} of ${IMAGE_BYTES}`);
|
|
147
|
+
console.log(`\nVERDICT: ${r.pass ? "PASS" : "FAIL"}`);
|
|
148
|
+
if (r.pass && r.drift.length) {
|
|
149
|
+
console.log("Drift candidates present — pass, but record them with the capture.");
|
|
150
|
+
}
|
|
151
|
+
return r.pass ? 0 : 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Drift floor across N captures of the same checkpoint: every address that
|
|
156
|
+
* differed in ANY pairing. Reported as a floor, never as a complete set —
|
|
157
|
+
* more captures can only widen it.
|
|
158
|
+
*/
|
|
159
|
+
function cmdFloor(argv) {
|
|
160
|
+
const limit = limitFrom(argv);
|
|
161
|
+
const paths = argv.filter((s) => !s.startsWith("--") && !/^\d+$/.test(s));
|
|
162
|
+
if (paths.length < 2) throw new Error("floor needs at least two image paths");
|
|
163
|
+
|
|
164
|
+
const imgs = paths.map((p) => ({ path: p, buf: loadImage(p) }));
|
|
165
|
+
for (const i of imgs) console.log(`${basename(i.path)} sha256 ${sha256(i.buf)}`);
|
|
166
|
+
|
|
167
|
+
const floor = new Map(); // addr -> Set of distinct values seen
|
|
168
|
+
let worstPair = null;
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < imgs.length; i++) {
|
|
171
|
+
for (let j = i + 1; j < imgs.length; j++) {
|
|
172
|
+
const r = compare(imgs[i].buf, imgs[j].buf);
|
|
173
|
+
for (const rec of [...r.volatile, ...r.drift, ...r.divergence]) {
|
|
174
|
+
if (!floor.has(rec.addr)) floor.set(rec.addr, new Set());
|
|
175
|
+
floor.get(rec.addr).add(rec.a);
|
|
176
|
+
floor.get(rec.addr).add(rec.b);
|
|
177
|
+
}
|
|
178
|
+
const label = `${basename(imgs[i].path)} vs ${basename(imgs[j].path)}`;
|
|
179
|
+
console.log(
|
|
180
|
+
`\n${label}: ${r.volatile.length} volatile, ${r.drift.length} drift, ${r.divergence.length} divergence -> ${r.pass ? "PASS" : "FAIL"}`,
|
|
181
|
+
);
|
|
182
|
+
if (!r.pass && (!worstPair || r.divergence.length > worstPair.n)) {
|
|
183
|
+
worstPair = { label, n: r.divergence.length };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const addrs = [...floor.keys()].sort((x, y) => x - y);
|
|
189
|
+
const vol = addrs.filter(isVolatile).length;
|
|
190
|
+
|
|
191
|
+
console.log(`\nDRIFT FLOOR: ${addrs.length} addresses (${vol} inside volatile spans)`);
|
|
192
|
+
const shown = limit === 0 ? addrs : addrs.slice(0, limit || 40);
|
|
193
|
+
for (const a of shown) {
|
|
194
|
+
const vals = [...floor.get(a)].sort((p, q) => p - q).map(hex2).join(" / ");
|
|
195
|
+
console.log(` ${hex4(a)}${isVolatile(a) ? " [volatile]" : " "} ${vals}`);
|
|
196
|
+
}
|
|
197
|
+
if (shown.length < addrs.length) {
|
|
198
|
+
console.log(` … ${addrs.length - shown.length} more (--limit 0 for all)`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(
|
|
202
|
+
"\nThis is a FLOOR, not a complete set — more captures of the same checkpoint can only widen it.",
|
|
203
|
+
);
|
|
204
|
+
if (worstPair) {
|
|
205
|
+
const n = worstPair.n;
|
|
206
|
+
console.log(`Worst pairing: ${worstPair.label} (${n} divergence${n === 1 ? "" : "s"}).`);
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** SHA-256 and size of each image, for recording alongside a capture. */
|
|
212
|
+
function cmdDigest(argv) {
|
|
213
|
+
const paths = argv.filter((s) => !s.startsWith("--"));
|
|
214
|
+
if (!paths.length) throw new Error("digest needs at least one image path");
|
|
215
|
+
for (const p of paths) {
|
|
216
|
+
const buf = readFileSync(p);
|
|
217
|
+
const ok = buf.length === IMAGE_BYTES;
|
|
218
|
+
console.log(
|
|
219
|
+
`${sha256(buf)} ${buf.length} bytes${ok ? "" : " *** NOT 65536 — not a full image ***"} ${basename(p)}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function limitFrom(argv) {
|
|
226
|
+
const i = argv.indexOf("--limit");
|
|
227
|
+
if (i < 0) return undefined;
|
|
228
|
+
const n = Number(argv[i + 1]);
|
|
229
|
+
if (!Number.isInteger(n) || n < 0) throw new Error("--limit needs a non-negative integer");
|
|
230
|
+
return n;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const commands = { compare: cmdCompare, floor: cmdFloor, digest: cmdDigest };
|
|
234
|
+
|
|
235
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
236
|
+
if (!cmd || !commands[cmd]) {
|
|
237
|
+
console.error(`usage: node compare.mjs <command>
|
|
238
|
+
|
|
239
|
+
compare <a.bin> <b.bin> [--limit N] classify every difference, print a verdict
|
|
240
|
+
floor <a.bin> <b.bin> [...] [--limit N] drift floor across N captures of one checkpoint
|
|
241
|
+
digest <image.bin>... sha256 + size, for the capture record
|
|
242
|
+
|
|
243
|
+
Volatile (counted, excluded from the verdict): $0000-$0001, $0100-$01FF, $0200-$03FF, $D000-$DFFF.
|
|
244
|
+
$D000-$DFFF is I/O, not RAM — reading it samples live hardware, so it can never be stable.
|
|
245
|
+
One differing bit is drift and passes; two or more is divergence and fails.
|
|
246
|
+
--limit 0 prints every row. Exit status is 1 on a FAIL verdict.
|
|
247
|
+
|
|
248
|
+
Images come from the capture procedure in this skill's SKILL.md, via mcp__plugin_c64-re-tools_vice__*.
|
|
249
|
+
This script contacts nothing.`);
|
|
250
|
+
process.exit(cmd ? 1 : 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
process.exit(commands[cmd](rest));
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.error(`error: ${e.message}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Direct byte-level .d64 parser -- the permanent, sanctioned replacement for
|
|
3
|
+
// the forbidden vice_disk_list tool (T-01-03). Never calls any vice_* tool
|
|
4
|
+
// or touches the emulator at all: this is pure Node over the disk-image
|
|
5
|
+
// bytes, which is why it works whether or not VICE happens to be up.
|
|
6
|
+
//
|
|
7
|
+
// Standard 35-track 1541 layout: BAM at track 18 sector 0, directory chain
|
|
8
|
+
// from track 18 sector 1, four sector-count zones (21/19/18/17 sectors per
|
|
9
|
+
// track). Plain 174848-byte, 35-track, no-error-info images are assumed
|
|
10
|
+
// are plain 174848-byte, 35-track, no-error-info images -- no extended
|
|
11
|
+
// (40-track) or error-byte variants to handle.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
|
|
17
|
+
|
|
18
|
+
export function readImage(path) {
|
|
19
|
+
return readFileSync(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The four sector-count zones of a standard 35-track 1541 image. */
|
|
23
|
+
export function sectorsPerTrack(track) {
|
|
24
|
+
if (!Number.isInteger(track) || track < 1 || track > 35) {
|
|
25
|
+
throw new Error(`sectorsPerTrack: track ${track} out of range 1-35`);
|
|
26
|
+
}
|
|
27
|
+
if (track <= 17) return 21;
|
|
28
|
+
if (track <= 24) return 19;
|
|
29
|
+
if (track <= 30) return 18;
|
|
30
|
+
return 17;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Byte offset of the start of {track, sector} in a flat 35-track image. */
|
|
34
|
+
export function tsToOffset(track, sector) {
|
|
35
|
+
if (!Number.isInteger(track) || track < 1 || track > 35) {
|
|
36
|
+
throw new Error(`tsToOffset: track ${track} out of range 1-35`);
|
|
37
|
+
}
|
|
38
|
+
const spt = sectorsPerTrack(track);
|
|
39
|
+
if (!Number.isInteger(sector) || sector < 0 || sector >= spt) {
|
|
40
|
+
throw new Error(`tsToOffset: sector ${sector} out of range for track ${track} (0-${spt - 1}, this track has ${spt} sectors)`);
|
|
41
|
+
}
|
|
42
|
+
let offset = 0;
|
|
43
|
+
for (let t = 1; t < track; t++) offset += sectorsPerTrack(t) * 256;
|
|
44
|
+
return offset + sector * 256;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isInImage(track, sector) {
|
|
48
|
+
if (!Number.isInteger(track) || track < 1 || track > 35) return false;
|
|
49
|
+
if (!Number.isInteger(sector) || sector < 0) return false;
|
|
50
|
+
return sector < sectorsPerTrack(track);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Directory/disk-name bytes are PETSCII, padded with $A0. Every byte this
|
|
55
|
+
* project's two disks actually use in a name (A-Z, digits, space, parens)
|
|
56
|
+
* sits at the same code point in PETSCII as in ASCII/Latin-1, so only the
|
|
57
|
+
* $A0 padding needs stripping -- there is no general PETSCII<->ASCII table
|
|
58
|
+
* here, on purpose, since one is not needed for what these disks contain.
|
|
59
|
+
*/
|
|
60
|
+
function petsciiName(bytes) {
|
|
61
|
+
let end = bytes.length;
|
|
62
|
+
while (end > 0 && bytes[end - 1] === 0xa0) end--;
|
|
63
|
+
return Buffer.from(bytes.subarray(0, end)).toString("latin1");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FILE_TYPES = { 0: "DEL", 1: "SEQ", 2: "PRG", 3: "USR", 4: "REL" };
|
|
67
|
+
|
|
68
|
+
/** Read track 18 sector 0: disk name/id, DOS type, and per-track free counts. */
|
|
69
|
+
export function parseBam(buffer) {
|
|
70
|
+
const off = tsToOffset(18, 0);
|
|
71
|
+
const bam = buffer.subarray(off, off + 256);
|
|
72
|
+
|
|
73
|
+
const perTrack = [];
|
|
74
|
+
for (let t = 1; t <= 35; t++) {
|
|
75
|
+
const eoff = 4 + (t - 1) * 4;
|
|
76
|
+
perTrack.push({
|
|
77
|
+
track: t,
|
|
78
|
+
free: bam[eoff],
|
|
79
|
+
sectors_per_track: sectorsPerTrack(t),
|
|
80
|
+
bitmap: [bam[eoff + 1], bam[eoff + 2], bam[eoff + 3]],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const occupiedTracks = perTrack.filter((t) => t.free < t.sectors_per_track).map((t) => t.track);
|
|
85
|
+
const occupiedRanges = [];
|
|
86
|
+
for (const t of occupiedTracks) {
|
|
87
|
+
const last = occupiedRanges[occupiedRanges.length - 1];
|
|
88
|
+
if (last && last.end === t - 1) last.end = t;
|
|
89
|
+
else occupiedRanges.push({ start: t, end: t });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
first_dir_track: bam[0],
|
|
94
|
+
first_dir_sector: bam[1],
|
|
95
|
+
dos_version: bam[2],
|
|
96
|
+
disk_name: petsciiName(bam.subarray(0x90, 0x90 + 16)),
|
|
97
|
+
disk_id: Buffer.from(bam.subarray(0xa2, 0xa2 + 2)).toString("latin1"),
|
|
98
|
+
dos_type: Buffer.from(bam.subarray(0xa5, 0xa5 + 2)).toString("latin1"),
|
|
99
|
+
per_track: perTrack,
|
|
100
|
+
occupied_tracks: occupiedTracks,
|
|
101
|
+
occupied_ranges: occupiedRanges,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isTrackFullyFree(bam, track) {
|
|
106
|
+
const entry = bam.per_track.find((t) => t.track === track);
|
|
107
|
+
return !!entry && entry.free === entry.sectors_per_track;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Walk the directory chain from track 18 sector 1 (by default). Guards
|
|
112
|
+
* against a malicious or corrupt next-sector pointer with a visited set: a
|
|
113
|
+
* sector, once processed, can never be re-entered, so even a
|
|
114
|
+
* self-referential or cyclic pointer stops the walk (reported in
|
|
115
|
+
* `chain_error`) rather than looping forever.
|
|
116
|
+
*
|
|
117
|
+
* Each entry is flagged `suspicious` -- with the specific reason(s) named,
|
|
118
|
+
* never a bare boolean -- when its block count is 0, when its first
|
|
119
|
+
* track/sector falls outside the image, or when its first track/sector
|
|
120
|
+
* points into a track the BAM reports as entirely free (0 sectors
|
|
121
|
+
* allocated): exactly the signature of a faked directory entry that claims
|
|
122
|
+
* a file that was never actually written to disk.
|
|
123
|
+
*/
|
|
124
|
+
export function parseDirectory(buffer, { startTrack = 18, startSector = 1 } = {}) {
|
|
125
|
+
const bam = parseBam(buffer);
|
|
126
|
+
const entries = [];
|
|
127
|
+
const visited = new Set();
|
|
128
|
+
let track = startTrack;
|
|
129
|
+
let sector = startSector;
|
|
130
|
+
let chainError = null;
|
|
131
|
+
|
|
132
|
+
for (;;) {
|
|
133
|
+
const key = `${track}/${sector}`;
|
|
134
|
+
if (visited.has(key)) {
|
|
135
|
+
chainError = `directory chain revisited ${key} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
visited.add(key);
|
|
139
|
+
if (!isInImage(track, sector)) {
|
|
140
|
+
chainError = `directory chain pointer ${key} is outside the image -- stopped`;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const off = tsToOffset(track, sector);
|
|
145
|
+
const sec = buffer.subarray(off, off + 256);
|
|
146
|
+
const nextTrack = sec[0];
|
|
147
|
+
const nextSector = sec[1];
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < 8; i++) {
|
|
150
|
+
const e = sec.subarray(i * 32, i * 32 + 32);
|
|
151
|
+
const typeByte = e[2];
|
|
152
|
+
const firstTrack = e[3];
|
|
153
|
+
const firstSector = e[4];
|
|
154
|
+
const nameBytes = e.subarray(5, 21);
|
|
155
|
+
const blocks = e[30] | (e[31] << 8);
|
|
156
|
+
|
|
157
|
+
// An all-zero type byte with a blank/padded name is an unused slot,
|
|
158
|
+
// not a file -- never listed as an entry.
|
|
159
|
+
const isEmptySlot =
|
|
160
|
+
typeByte === 0 && firstTrack === 0 && firstSector === 0 &&
|
|
161
|
+
[...nameBytes].every((b) => b === 0xa0 || b === 0x00);
|
|
162
|
+
if (isEmptySlot) continue;
|
|
163
|
+
|
|
164
|
+
const reasons = [];
|
|
165
|
+
if (blocks === 0) reasons.push("block count is 0");
|
|
166
|
+
if (!isInImage(firstTrack, firstSector)) {
|
|
167
|
+
reasons.push(`first track/sector ${firstTrack}/${firstSector} is outside the image`);
|
|
168
|
+
} else if (isTrackFullyFree(bam, firstTrack)) {
|
|
169
|
+
reasons.push(`first track ${firstTrack} is reported entirely free by the BAM (0 sectors allocated) -- the file cannot really start there`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
entries.push({
|
|
173
|
+
dir_track: track,
|
|
174
|
+
dir_sector: sector,
|
|
175
|
+
entry_index: i,
|
|
176
|
+
type: FILE_TYPES[typeByte & 0x0f] ?? `unknown(0x${(typeByte & 0x0f).toString(16)})`,
|
|
177
|
+
closed: !!(typeByte & 0x80),
|
|
178
|
+
locked: !!(typeByte & 0x40),
|
|
179
|
+
name: petsciiName(nameBytes),
|
|
180
|
+
first_track: firstTrack,
|
|
181
|
+
first_sector: firstSector,
|
|
182
|
+
blocks,
|
|
183
|
+
suspicious: reasons.length > 0,
|
|
184
|
+
suspicious_reasons: reasons,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (nextTrack === 0) break; // end of chain, by DOS convention
|
|
189
|
+
track = nextTrack;
|
|
190
|
+
sector = nextSector;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { entries, chain_error: chainError };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -------------------------------------------------------------------- CLI
|
|
197
|
+
|
|
198
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
199
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
200
|
+
const opt = (name, fallback) => {
|
|
201
|
+
const i = rest.indexOf(`--${name}`);
|
|
202
|
+
return i === -1 ? fallback : rest[i + 1];
|
|
203
|
+
};
|
|
204
|
+
const jsonFlag = rest.includes("--json");
|
|
205
|
+
|
|
206
|
+
function run() {
|
|
207
|
+
if (cmd !== "directory" && cmd !== "bam") {
|
|
208
|
+
console.log(`usage: node ${fileURLToPath(import.meta.url)} <directory|bam> --image <path.d64> [--json]`);
|
|
209
|
+
process.exitCode = cmd ? 1 : 0;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const imagePath = opt("image");
|
|
213
|
+
if (!imagePath) die(`usage: ${cmd} --image <path.d64> [--json]`);
|
|
214
|
+
const buffer = readImage(resolve(imagePath));
|
|
215
|
+
|
|
216
|
+
if (cmd === "directory") {
|
|
217
|
+
const result = parseDirectory(buffer);
|
|
218
|
+
if (jsonFlag) {
|
|
219
|
+
console.log(JSON.stringify(result, null, 2));
|
|
220
|
+
} else {
|
|
221
|
+
for (const e of result.entries) {
|
|
222
|
+
const flag = e.suspicious ? ` SUSPICIOUS: ${e.suspicious_reasons.join("; ")}` : "";
|
|
223
|
+
console.log(`${e.type} "${e.name}" first=${e.first_track}/${e.first_sector} blocks=${e.blocks}${flag}`);
|
|
224
|
+
}
|
|
225
|
+
if (result.chain_error) console.log(`chain error: ${result.chain_error}`);
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const bam = parseBam(buffer);
|
|
231
|
+
if (jsonFlag) {
|
|
232
|
+
console.log(JSON.stringify(bam, null, 2));
|
|
233
|
+
} else {
|
|
234
|
+
console.log(`disk name: "${bam.disk_name}" id: ${bam.disk_id} dos type: ${bam.dos_type}`);
|
|
235
|
+
console.log(`first dir sector: ${bam.first_dir_track}/${bam.first_dir_sector}`);
|
|
236
|
+
console.log(
|
|
237
|
+
`occupied track ranges: ${bam.occupied_ranges.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)).join(", ")}`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
run();
|
|
243
|
+
}
|