@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,553 @@
1
+ #!/usr/bin/env node
2
+ // Reference driver for the C64 memory map.
3
+ //
4
+ // One job: answer "what is at this address?", and apply that answer to a
5
+ // 6502 listing so bare numeric operands read as *documented* assembly. Every
6
+ // address is resolved against the published Commodore 64 memory-map tables (see
7
+ // SOURCES below). Everything here is information.
8
+ //
9
+ // Self-contained by design: no imports outside the Node standard library, no
10
+ // sibling skill, no emulator, no other tool. `lookup` and `annotate` run purely
11
+ // off the committed memmap.json, so they work offline and anywhere. Only
12
+ // `memmap`, which rebuilds that file from the four upstream sources, needs a
13
+ // network. A listing to annotate is read from a file or stdin -- whatever
14
+ // produced it is none of this script's business.
15
+ //
16
+ // Node >= 18 (global fetch). No dependencies on purpose.
17
+
18
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, join, resolve } from "node:path";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ // memmap.json lives at the skill root, one level up from scripts/, by decision
24
+ // (D-03): only .mjs modules move into scripts/, data files stay put.
25
+ const MEMMAP_JSON = join(HERE, "..", "memmap.json");
26
+
27
+ // Reference tables, merged into one address -> meaning index. `kind` selects the
28
+ // parser; add a row here to pull in another aay page (e.g. basromma.htm for the
29
+ // BASIC ROM) and re-run `node driver.mjs memmap`.
30
+ const SOURCES = [
31
+ { id: "sta", kind: "staTable", url: "https://sta.c64.org/cbm64mem.html" },
32
+ { id: "zim", kind: "zimmers", url: "https://www.zimmers.net/anonftp/pub/cbm/maps/C64.MemoryMap.txt" },
33
+ { id: "kernal", kind: "aayList", url: "http://unusedino.de/ec64/technical/aay/c64/krnromma.htm" },
34
+ { id: "io", kind: "c64io", url: "https://www.zimmers.net/anonftp/pub/cbm/maps/C64io.txt" },
35
+ ];
36
+
37
+ // On an exact tie (several sources describe the same single address), prefer
38
+ // sta.c64.org -- its prose is the most specific ("Border color (only bits
39
+ // #0-#3)" vs "Border Color"). The other sources win wherever sta has only a
40
+ // block entry, which is all of ROM.
41
+ const SRC_RANK = { sta: 0, zim: 1, kernal: 2, io: 3 };
42
+
43
+ // Fields worth keeping from a source that loses on specificity: the canonical
44
+ // assembler symbol (zimmers) and a chip register offset like "VIC+17" (emitted
45
+ // by parseAayList, so adding vicmain.htm/sidmain.htm/ciamain.htm to SOURCES
46
+ // lights it up). Grafted onto whichever entry wins, so a single comment can
47
+ // carry prose + symbol + register.
48
+ const GRAFT_FIELDS = ["sym", "reg"];
49
+
50
+ // Entries wider than this are context, not a specific register: "$E000-$FFFF
51
+ // KERNAL ROM" inline on every branch target is pure noise. Wide hits still show
52
+ // up in the header block.
53
+ // Admits screen RAM ($0400, 1000 bytes) and the $C000-$CFFF block, but not the
54
+ // 8 KB BASIC/KERNAL ROM blocks. Only applies to non-flow instructions.
55
+ const INLINE_SPAN_MAX = 4096;
56
+
57
+ // ------------------------------------------------------------- memory-map table
58
+
59
+ // This image's python3 has no `html` stdlib module and the page is ISO-8859-1,
60
+ // so both the decode and the entity unescape are done by hand here.
61
+ function unescapeHtml(s) {
62
+ const named = {
63
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
64
+ ndash: "-", mdash: "-", hellip: "...", deg: "deg", times: "x", eacute: "e",
65
+ };
66
+ return s
67
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(+d))
68
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
69
+ .replace(/&([a-z]+);/gi, (m, n) => named[n.toLowerCase()] ?? m);
70
+ }
71
+
72
+ function textify(frag) {
73
+ let t = frag
74
+ .replace(/<a\b[^>]*>.*?<\/a>/gis, " ")
75
+ .replace(/<(li|p|ul|\/ul|br)\b[^>]*>/gi, "\n")
76
+ .replace(/<[^>]+>/g, " ");
77
+ t = unescapeHtml(t)
78
+ .split("\n")
79
+ .map((l) => l.replace(/[ \t ]+/g, " ").trim())
80
+ .filter(Boolean)
81
+ .join(" ");
82
+ return t.trim();
83
+ }
84
+
85
+ /** First sentence — short enough to sit at the end of an asm line. */
86
+ function shortLabel(desc) {
87
+ let m = desc.match(/^(.{0,90}?[.:])(\s|$)/);
88
+ let lab = (m ? m[1] : desc.slice(0, 90)).replace(/[.:]+$/, "").trim();
89
+ // "$D012 -> Read" is useless: some entries open with a bare access-mode word
90
+ // and put the actual meaning after the colon. Reach past it.
91
+ if (/^(read|write|bits?|values?)$/i.test(lab)) {
92
+ const rest = desc.slice(desc.indexOf(":") + 1).trim();
93
+ const m2 = rest.match(/^(.{0,80}?[.:])(\s|$)/);
94
+ lab = `${lab}: ${(m2 ? m2[1] : rest.slice(0, 80)).replace(/[.:]+$/, "").trim()}`;
95
+ }
96
+ // Trim to a word boundary rather than mid-word.
97
+ if (lab.length > 88) lab = lab.slice(0, 88).replace(/\s+\S*$/, "") + "...";
98
+ return lab;
99
+ }
100
+
101
+ // The "All About Your 64" pages are flat lists of single addresses:
102
+ // <a href="vic17.htm">$D011/53265/VIC+17</a> Control Register 1
103
+ // <a href="rome000.htm">$E000/57344</a> EXP continued From BASIC ROM
104
+ // The chip/offset token is optional (the KERNAL listing omits it). Descriptions
105
+ // run to end of line.
106
+ const AAY_RE =
107
+ /<a\s+href="[^"]*">\$([0-9A-F]{4})\/\d+(?:\/([A-Za-z0-9]+\+\d+))?<\/a>\s*([^\n|<]*)/gi;
108
+
109
+ // zimmers.net C64.MemoryMap.txt, section 1 — plain text, space-aligned:
110
+ // LABEL HEX DEC DESCRIPTION
111
+ // TXTTAB 002B-002C 43 Pointer: Start of BASIC Text Area ($0801).
112
+ // ADRAY1 0003-0004 3 Jump Vector: Convert FAC to Integer in (A/Y)
113
+ // ($B1AA). <- continuation line
114
+ // The label is this file's unique contribution: the canonical assembler symbol
115
+ // names, which none of the other sources carry. The label is optional (block
116
+ // rows like "0800-9FFF Normal BASIC Program space." have none).
117
+ const ZIM_ROW = /^([A-Z0-9]{1,9})?\s+([0-9A-F]{4})(?:-([0-9A-F]{4}))?\s+\d+\s+(\S.*)$/;
118
+
119
+ function parseZimmers(txt, section) {
120
+ const lines = txt.split("\n");
121
+ // Section 2 ("INPUT/OUTPUT ASSIGNMENTS") is deliberately skipped: it is
122
+ // tab-separated bit-level detail already covered by the aay chip pages, and it
123
+ // carries OCR damage ("DEOO-DEFF", "Sprite O X Pos") that would inject bogus
124
+ // addresses.
125
+ const stop = lines.findIndex((l) => /INPUT\/OUTPUT ASSIGNMENTS/i.test(l));
126
+ const body = lines.slice(0, stop === -1 ? lines.length : stop);
127
+
128
+ const entries = [];
129
+ for (const line of body) {
130
+ if (!line.trim()) continue;
131
+ // An inline listing of the CHRGET routine sits mid-table; its rows look like
132
+ // " ,0073 INC $7A" and must not be read as addresses.
133
+ if (/,[0-9A-F]{4}\s/.test(line)) continue;
134
+ const m = line.match(ZIM_ROW);
135
+ if (m) {
136
+ const start = parseInt(m[2], 16);
137
+ entries.push({
138
+ start,
139
+ end: m[3] ? parseInt(m[3], 16) : start,
140
+ sym: m[1] || null,
141
+ desc: m[4].trim(),
142
+ section,
143
+ });
144
+ } else if (/^\s{20,}\S/.test(line) && entries.length) {
145
+ entries[entries.length - 1].desc += " " + line.trim();
146
+ }
147
+ }
148
+ for (const e of entries) {
149
+ e.desc = e.desc.replace(/\s+/g, " ").trim();
150
+ e.label = shortLabel(e.desc);
151
+ }
152
+ return entries;
153
+ }
154
+
155
+ // zimmers.net C64io.txt — the I/O map, tab-separated with a variable number of
156
+ // tabs, so fields are split and empties dropped rather than column-sliced:
157
+ // D011 53265 VIC Control Register
158
+ // 7 Raster Compare: (Bit 8) See 53266 <- bit row
159
+ // 2-0 Smooth Scroll to Y Dot-Position (0-7)
160
+ // Its edge over a plain register list is the per-bit breakdown, kept on the
161
+ // register entry and printed by `lookup`.
162
+ const IO_ADDR = /^([0-9A-F]{4})(?:-([0-9A-F]{4}))?$/;
163
+ const IO_BIT = /^[0-7](?:-[0-7])?$/;
164
+
165
+ function parseC64io(txt) {
166
+ const entries = [];
167
+ let section = null;
168
+ let last = null;
169
+ for (const line of txt.split("\n")) {
170
+ if (!line.trim() || line.trim().startsWith(";")) continue;
171
+ const f = line.split("\t").map((x) => x.trim()).filter(Boolean);
172
+ if (!f.length) continue;
173
+
174
+ const am = f[0].match(IO_ADDR);
175
+ if (am && f.length >= 2) {
176
+ const start = parseInt(am[1], 16);
177
+ const end = am[2] ? parseInt(am[2], 16) : start;
178
+ const desc = f.slice(2).join(" ").trim();
179
+ // A range row spanning a whole chip is a section header; it is also a
180
+ // legitimate (wide) entry, so keep it AND use it as the section.
181
+ if (am[2]) section = desc;
182
+ if (!desc) continue;
183
+ last = { start, end, desc, label: shortLabel(desc), section, bits: [] };
184
+ entries.push(last);
185
+ } else if (IO_BIT.test(f[0]) && last) {
186
+ last.bits.push({ bit: f[0], desc: f.slice(1).join(" ").trim() });
187
+ } else if (last && f.length === 1) {
188
+ // Wrapped description text.
189
+ if (last.bits.length) last.bits[last.bits.length - 1].desc += " " + f[0];
190
+ else {
191
+ last.desc += " " + f[0];
192
+ last.label = shortLabel(last.desc);
193
+ }
194
+ }
195
+ }
196
+ for (const e of entries) if (!e.bits.length) delete e.bits;
197
+ return entries;
198
+ }
199
+
200
+ function parseAayList(htmlText, section) {
201
+ const entries = [];
202
+ for (const m of htmlText.matchAll(AAY_RE)) {
203
+ const desc = unescapeHtml(m[3]).replace(/\s+/g, " ").trim();
204
+ if (!desc || desc === "-") continue; // a few rows are placeholders
205
+ const addr = parseInt(m[1], 16);
206
+ entries.push({
207
+ start: addr, end: addr,
208
+ label: desc, desc,
209
+ reg: m[2] || null, // e.g. "VIC+17"
210
+ section,
211
+ });
212
+ }
213
+ return entries;
214
+ }
215
+
216
+ function parseStaTable(htmlText) {
217
+ const entries = [];
218
+ let section = null;
219
+ for (const row of htmlText.split(/<TR\b/i).slice(1)) {
220
+ const sec = row.match(/<TD[^>]*COLSPAN=2[^>]*>\s*<B>([\s\S]*?)<\/B>/i);
221
+ if (sec) {
222
+ section = textify(sec[1]);
223
+ continue;
224
+ }
225
+ const m = row.match(/<TD[^>]*>\s*\$([0-9A-F]{4})(?:-\$([0-9A-F]{4}))?\s*<BR>/i);
226
+ if (!m) continue;
227
+ const start = parseInt(m[1], 16);
228
+ const end = m[2] ? parseInt(m[2], 16) : start;
229
+ const tds = row.split(/<TD[^>]*>/i);
230
+ const desc = tds.length > 2 ? textify(tds.slice(2).join(" ")) : "";
231
+ entries.push({ start, end, label: shortLabel(desc), section, desc });
232
+ }
233
+ entries.sort((a, b) => a.start - b.start || a.end - b.end);
234
+ return entries;
235
+ }
236
+
237
+ const SECTION_FOR = {
238
+ zim: "C64 memory map (labelled)",
239
+ kernal: "KERNAL ROM routine",
240
+ };
241
+
242
+ // Every page here is served as ISO-8859-1 / Latin-1 in practice; decoding as
243
+ // UTF-8 mangles the entities. Decode explicitly.
244
+ async function fetchLatin1(url) {
245
+ const res = await fetch(url);
246
+ if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
247
+ return new TextDecoder("iso-8859-1").decode(new Uint8Array(await res.arrayBuffer()));
248
+ }
249
+
250
+ async function cmdMemmap() {
251
+ const all = [];
252
+ for (const s of SOURCES) {
253
+ process.stderr.write(`fetching ${s.url} ... `);
254
+ const htmlText = await fetchLatin1(s.url);
255
+ const parsers = {
256
+ staTable: () => parseStaTable(htmlText),
257
+ zimmers: () => parseZimmers(htmlText, SECTION_FOR[s.id]),
258
+ c64io: () => parseC64io(htmlText),
259
+ aayList: () => parseAayList(htmlText, SECTION_FOR[s.id]),
260
+ };
261
+ const got = parsers[s.kind]();
262
+ if (!got.length) throw new Error(`no entries from ${s.url} — layout changed?`);
263
+ got.forEach((e) => (e.src = s.id));
264
+ process.stderr.write(`${got.length} entries\n`);
265
+ all.push(...got);
266
+ }
267
+ // Per-source emptiness above is the real guard; this only catches a broad collapse.
268
+ if (all.length < 600) throw new Error(`only ${all.length} entries total — layout changed?`);
269
+ all.sort((a, b) => a.start - b.start || a.end - b.end);
270
+ writeFileSync(MEMMAP_JSON, JSON.stringify({ sources: SOURCES, entries: all }, null, 1));
271
+ console.log(`${all.length} entries from ${SOURCES.length} sources -> ${MEMMAP_JSON}`);
272
+ }
273
+
274
+ let MEMMAP = null;
275
+ function memmap() {
276
+ if (MEMMAP) return MEMMAP;
277
+ if (!existsSync(MEMMAP_JSON)) {
278
+ throw new Error(`no ${MEMMAP_JSON}; run: node driver.mjs memmap`);
279
+ }
280
+ MEMMAP = JSON.parse(readFileSync(MEMMAP_JSON, "utf8")).entries;
281
+ // Several sources describe the same address with different strengths. Whoever
282
+ // wins on specificity keeps its prose, but the symbol name and register offset
283
+ // from the others are grafted on so one comment carries all three. Keyed on
284
+ // the exact range so a block entry never inherits a register's symbol.
285
+ for (const f of GRAFT_FIELDS) {
286
+ const at = new Map();
287
+ for (const e of MEMMAP) if (e[f]) at.set(`${e.start}-${e.end}`, e[f]);
288
+ for (const e of MEMMAP) if (!e[f]) e[f] = at.get(`${e.start}-${e.end}`) ?? null;
289
+ }
290
+ return MEMMAP;
291
+ }
292
+
293
+ /**
294
+ * All entries covering `addr`, most specific first: narrowest span wins, then
295
+ * the richer source. This ordering is what makes ROM addresses resolve to a
296
+ * named routine instead of "KERNAL ROM (8192 bytes)".
297
+ */
298
+ function lookup(addr) {
299
+ return memmap()
300
+ .filter((e) => e.start <= addr && addr <= e.end)
301
+ .sort(
302
+ (a, b) =>
303
+ a.end - a.start - (b.end - b.start) ||
304
+ (SRC_RANK[a.src] ?? 9) - (SRC_RANK[b.src] ?? 9)
305
+ );
306
+ }
307
+
308
+ // --------------------------------------------------------- operand extraction
309
+
310
+ const hex = (n, w = 4) => "$" + n.toString(16).toUpperCase().padStart(w, "0");
311
+
312
+ /**
313
+ * Pull the address an instruction actually *touches* out of a line of listing
314
+ * text. Returns {addr, note} or null.
315
+ *
316
+ * Deliberately returns null for immediates: in `LDA #$D0` the $D0 is a value,
317
+ * not a location, and annotating it "processor port" is actively misleading.
318
+ */
319
+ const MNEMONICS = ("ADC AND ASL BCC BCS BEQ BIT BMI BNE BPL BRK BVC BVS CLC CLD CLI CLV CMP " +
320
+ "CPX CPY DEC DEX DEY EOR INC INX INY JMP JSR LDA LDX LDY LSR NOP ORA PHA PHP PLA PLP " +
321
+ "ROL ROR RTI RTS SBC SEC SED SEI STA STX STY TAX TAY TSX TXA TXS TYA").split(" ");
322
+
323
+ // Anchoring on the real opcode set (rather than /[A-Z]{3}/) is what lets one
324
+ // regex cope with every listing style without being told which it is: a
325
+ // disassembler's "$EA34: AD 12 D0 LDA $D012", a bare "lda $d012", and
326
+ // hand-written "loop lda $d012" / "loop: lda $d012" alike. The \b after the
327
+ // mnemonic is load-bearing: it stops "sta" from matching inside the label
328
+ // "start".
329
+ const INSTR_RE = new RegExp(`\\b(${MNEMONICS.join("|")})\\b\\s*([^;]*)`, "i");
330
+
331
+ function operandAddr(instruction) {
332
+ const m = instruction.match(INSTR_RE);
333
+ if (!m) return null;
334
+ const [, mnemonic, rawOperand] = m;
335
+ const op = rawOperand.trim();
336
+ if (!op || op === "A") return null;
337
+ if (op.startsWith("#")) return null; // immediate — a value, not an address
338
+
339
+ // ($xx),Y ($xx,X) ($xxxx) -> the pointer itself is the interesting location
340
+ let ind = op.match(/^\(\$([0-9A-F]{2,4})\s*(?:,\s*X)?\)\s*(?:,\s*Y)?$/i);
341
+ if (ind) {
342
+ return { addr: parseInt(ind[1], 16), note: "pointer", mnemonic };
343
+ }
344
+ // $xx $xxxx optionally ,X / ,Y
345
+ let abs = op.match(/^\$([0-9A-F]{2,4})\s*(?:,\s*[XY])?$/i);
346
+ if (abs) {
347
+ const idx = /,\s*[XY]/i.test(op);
348
+ return { addr: parseInt(abs[1], 16), note: idx ? "indexed" : null, mnemonic };
349
+ }
350
+ return null;
351
+ }
352
+
353
+ const FLOW = new Set(["JMP", "JSR", "BNE", "BEQ", "BCC", "BCS", "BMI", "BPL", "BVC", "BVS", "RTS", "RTI"]);
354
+
355
+ // ------------------------------------------------------------------- annotate
356
+
357
+ function annotate(lines, { maxSpan = INLINE_SPAN_MAX, noHeader = false } = {}) {
358
+ const out = [];
359
+ const referenced = new Map();
360
+
361
+ // Each line is passed through byte-for-byte -- indentation, labels,
362
+ // directives, blank lines and existing comments -- and only a trailing `;`
363
+ // comment is appended, so documenting a .asm file never reformats it.
364
+ // INSTR_RE finds the mnemonic wherever in the line it sits, which is why the
365
+ // whole line can be handed to the operand parser unsliced.
366
+ for (const body of lines) {
367
+ if (!body.trim()) {
368
+ out.push(body);
369
+ continue;
370
+ }
371
+ const o = operandAddr(body);
372
+ let comment = "";
373
+ if (o) {
374
+ const hits = lookup(o.addr);
375
+ if (hits.length) {
376
+ const best = hits[0];
377
+ const span = best.end - best.start;
378
+ // Record every hit for the header, even the wide ones.
379
+ if (!referenced.has(best.start)) referenced.set(best.start, best);
380
+ // A branch into "$E000-$FFFF KERNAL ROM" teaches nobody anything, so
381
+ // flow instructions only get a comment when the hit is a specific
382
+ // vector. Data instructions are the opposite: `STA $0400,X` -> "screen
383
+ // memory (1000 bytes)" is exactly what a reader wants, so wide hits
384
+ // stay, up to maxSpan (which by default admits screen RAM and the
385
+ // $C000 block but not the 8 KB ROM blocks).
386
+ const isFlow = FLOW.has(o.mnemonic.toUpperCase());
387
+ if (isFlow ? span <= 2 : span <= maxSpan) {
388
+ const which = best.start === best.end ? hex(best.start) : `${hex(best.start)}-${hex(best.end)}`;
389
+ const tags = [best.sym, best.reg, o.note].filter(Boolean).join(", ");
390
+ comment = `; ${which}${tags ? ` (${tags})` : ""} = ${best.label}`;
391
+ }
392
+ }
393
+ }
394
+ out.push(comment ? `${body.padEnd(44)}${comment}` : body);
395
+ }
396
+
397
+ const header = [];
398
+ if (referenced.size && !noHeader) {
399
+ header.push(";; ------------------------------------------------------------------");
400
+ header.push(";; Addresses referenced by this listing");
401
+ for (const s of SOURCES) header.push(`;; ${s.url}`);
402
+ header.push(";; ------------------------------------------------------------------");
403
+ for (const e of [...referenced.values()].sort((a, b) => a.start - b.start)) {
404
+ const which = e.start === e.end ? hex(e.start) : `${hex(e.start)}-${hex(e.end)}`;
405
+ const tags = [e.sym, e.reg].filter(Boolean).join(", ");
406
+ header.push(`;; ${which.padEnd(14)}${e.label}${tags ? ` (${tags})` : ""}`);
407
+ if (e.section) header.push(`;; ${"".padEnd(14)} [${e.section}]`);
408
+ }
409
+ header.push(";; ------------------------------------------------------------------");
410
+ header.push("");
411
+ }
412
+ return header.concat(out).join("\n");
413
+ }
414
+
415
+ // -------------------------------------------------------------------- commands
416
+
417
+ // An address is accepted however the source it was copied from happened to write
418
+ // it, so no one has to convert by hand:
419
+ //
420
+ // $D011 $d011 0xD011 0XD011 D011h d011H D011 d011 hex
421
+ // %1101000000010001 binary
422
+ // 53265 decimal
423
+ //
424
+ // A base marker ($, 0x, trailing h, %) settles the base outright. Without one,
425
+ // two unambiguous readings remain: a token containing A-F can only be hex, and a
426
+ // plain run of decimal digits is read as decimal -- which is how the published
427
+ // tables print addresses ("53265 VIC Control Register"), so a number copied
428
+ // straight out of one lands on the right register. `#` is tolerated in front,
429
+ // for pasting an operand across as-is.
430
+ //
431
+ // Tried in order, so a marker always beats the markerless readings below it.
432
+ const ADDR_FORMS = [
433
+ [/^\$([0-9a-f]+)$/i, 16],
434
+ [/^0x([0-9a-f]+)$/i, 16],
435
+ [/^([0-9a-f]+)h$/i, 16],
436
+ [/^%([01]+)$/, 2],
437
+ [/^(\d+)$/, 10],
438
+ [/^([0-9a-f]+)$/i, 16],
439
+ ];
440
+
441
+ const ADDR_MAX = 0xffff;
442
+
443
+ function parseAddr(s) {
444
+ if (s == null) throw new Error("address required");
445
+ const t = String(s).trim().replace(/^#/, "").replace(/[_\s]/g, "");
446
+ for (const [re, base] of ADDR_FORMS) {
447
+ const m = t.match(re);
448
+ if (!m) continue;
449
+ const n = parseInt(m[1], base);
450
+ if (!Number.isFinite(n)) break;
451
+ if (n > ADDR_MAX) {
452
+ // Digits that overflow as hex but fit as decimal are almost always a
453
+ // decimal address that picked up a `$` on the way in.
454
+ const asDec = base === 16 && /^\d+$/.test(m[1]) ? parseInt(m[1], 10) : NaN;
455
+ const hint =
456
+ asDec <= ADDR_MAX ? ` For decimal ${m[1]}, drop the marker: ${m[1]} = ${hex(asDec)}.` : "";
457
+ throw new Error(
458
+ `${s} reads as ${n}, past the top of the 64K address space ` +
459
+ `($0000-${hex(ADDR_MAX)}).${hint}`
460
+ );
461
+ }
462
+ return n;
463
+ }
464
+ throw new Error(
465
+ `bad address: ${s}\n` +
466
+ ` hex $D011 / 0xD011 / D011h / D011, binary %1101000000010001, decimal 53265`
467
+ );
468
+ }
469
+
470
+ function flag(argv, name, def = null) {
471
+ const i = argv.indexOf(`--${name}`);
472
+ return i >= 0 ? argv[i + 1] : def;
473
+ }
474
+
475
+ const commands = {
476
+ memmap: cmdMemmap,
477
+
478
+ /** Full prose for an address — the "what is $D011 again?" command. */
479
+ async lookup(argv) {
480
+ for (const a of argv) {
481
+ const addr = parseAddr(a);
482
+ const hits = lookup(addr);
483
+ console.log(`\n=== ${hex(addr)} ===`);
484
+ if (!hits.length) {
485
+ console.log("(not in memory map)");
486
+ continue;
487
+ }
488
+ for (const e of hits) {
489
+ const which = e.start === e.end ? hex(e.start) : `${hex(e.start)}-${hex(e.end)}`;
490
+ const tags = [e.sym, e.reg].filter(Boolean).join(", ");
491
+ console.log(`${which}${tags ? ` (${tags})` : ""} [${e.section || "-"}] <${e.src}>`);
492
+ console.log(e.desc.replace(/(.{1,96})(\s|$)/g, " $1\n").trimEnd());
493
+ for (const b of e.bits || []) console.log(` bit ${b.bit.padEnd(4)} ${b.desc}`);
494
+ }
495
+ }
496
+ },
497
+
498
+ /**
499
+ * annotate --file listing.asm [--out f.asm] document a listing on disk
500
+ * annotate [--out f.asm] ... or one piped in on stdin
501
+ *
502
+ * The input is any text carrying 6502 mnemonics: hand-written source, or a
503
+ * listing from whichever disassembler produced it. Every line, blank ones
504
+ * included, is kept.
505
+ */
506
+ async annotate(argv) {
507
+ const outFile = flag(argv, "out");
508
+ const inFile = flag(argv, "file");
509
+ const maxSpan = Number(flag(argv, "max-span", INLINE_SPAN_MAX));
510
+ // A presence test, not `flag()`: `flag()` returns the argv element *following*
511
+ // the named flag, which for a valueless flag would silently swallow whatever
512
+ // comes next (e.g. `--no-header --file game.asm` would read "--file" as the
513
+ // value of `--no-header`).
514
+ const noHeader = argv.includes("--no-header");
515
+
516
+ // `--file -` and a bare `annotate` both mean stdin; fd 0 reads it whole.
517
+ const src = !inFile || inFile === "-" ? 0 : inFile;
518
+ const lines = readFileSync(src, "utf8").replace(/\n$/, "").split("\n");
519
+
520
+ const text = annotate(lines, { maxSpan, noHeader });
521
+ if (outFile) {
522
+ writeFileSync(outFile, text + "\n");
523
+ console.log(`wrote ${outFile} (${text.split("\n").length} lines)`);
524
+ } else {
525
+ console.log(text);
526
+ }
527
+ },
528
+
529
+ };
530
+
531
+ // `lookup` is exported so anything else that needs to name an address can reuse
532
+ // this table instead of reimplementing it. Guarding the CLI dispatch below keeps
533
+ // such an import from also running the CLI.
534
+ export { lookup };
535
+
536
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
537
+ const [cmd, ...rest] = process.argv.slice(2);
538
+ if (!cmd || !commands[cmd]) {
539
+ console.error(`usage: node driver.mjs <command>
540
+
541
+ lookup <addr>... full memory-map prose for an address
542
+ annotate --file <listing> document a listing or .asm file
543
+ [--out f.asm] [--max-span N] [--no-header]
544
+ annotate ... the same, reading stdin
545
+ memmap (re)build memmap.json from the four sources
546
+ (the only command needing a network)`);
547
+ process.exit(cmd ? 1 : 0);
548
+ }
549
+ commands[cmd](rest).catch((e) => {
550
+ console.error(`error: ${e.message}`);
551
+ process.exit(1);
552
+ });
553
+ }