@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,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
+ }