@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,263 @@
1
+ #!/usr/bin/env node
2
+ // ACME -> C64 assembler driver. Target is fixed: C64, 6510 CPU, cbm output.
3
+ // Scope is assembling only: source in, .prg + symbol files out. Running the
4
+ // result on a C64 belongs to the emulator skill.
5
+ import { spawnSync } from "node:child_process";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { dirname, join, basename, relative, isAbsolute } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const SELF = fileURLToPath(import.meta.url);
11
+ const HERE = dirname(SELF);
12
+
13
+ // The ACME library (<cbm/c64/vic.a> and friends) lives wherever the package put
14
+ // it. Probe instead of assuming; validated by a file we actually include.
15
+ const LIB_MARKER = join("cbm", "c64", "vic.a");
16
+ function findAcmeLib() {
17
+ const tried = [];
18
+ for (const c of [
19
+ process.env.ACME,
20
+ "/usr/local/share/acme", "/usr/share/acme", "/usr/lib/acme",
21
+ process.env.HOME && join(process.env.HOME, ".acme"),
22
+ ].filter(Boolean)) {
23
+ tried.push(c);
24
+ if (existsSync(join(c, LIB_MARKER))) return { path: c, tried };
25
+ }
26
+ return { path: null, tried };
27
+ }
28
+ const ACME_LIB = findAcmeLib();
29
+
30
+ // How to refer to this script in hints, from wherever we were run.
31
+ function selfPath() {
32
+ const r = relative(process.cwd(), SELF);
33
+ return !r || r.startsWith("..") || isAbsolute(r) ? SELF : r;
34
+ }
35
+
36
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
37
+
38
+ // ------------------------------------------------------------------- build
39
+
40
+ // ACME's --msvc format: file(line) : Error (Zone <z>): message.
41
+ const MSVC = /^(.*?)\((\d+)\)\s*:\s*(Error|Warning|Serious error)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$/;
42
+
43
+ function parseDiagnostics(text) {
44
+ const out = [];
45
+ for (const line of text.split("\n")) {
46
+ const m = line.match(MSVC);
47
+ if (m) {
48
+ out.push({
49
+ file: m[1], line: Number(m[2]),
50
+ severity: m[3].toLowerCase().replace(" ", "_"),
51
+ zone: m[4] || null, message: m[5].trim(),
52
+ });
53
+ } else if (line.trim()) {
54
+ out.push({ file: null, line: null, severity: "note", zone: null, message: line.trim() });
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // The symbol list marks address-typed symbols with a leading "!addr" and
61
+ // never-referenced ones with a trailing "; unused". Both matter downstream.
62
+ function parseSymbols(path) {
63
+ if (!existsSync(path)) return [];
64
+ return readFileSync(path, "utf8").split("\n").flatMap((raw) => {
65
+ const m = raw.match(/^(!addr\s+)?(\S+)\s*=\s*(\S+?)\s*(?:;\s*(.*))?$/);
66
+ if (!m) return [];
67
+ return [{
68
+ name: m[2], value: m[3],
69
+ isAddress: Boolean(m[1]),
70
+ used: !/^unused/.test(m[4] || ""),
71
+ }];
72
+ });
73
+ }
74
+
75
+ // ACME's label file lists every global symbol, constants included, in address
76
+ // form. A debugger reads `viccolor_WHITE = $1` as a name for address $0001 and
77
+ // relabels the 6510 processor port with it. Keep referenced addresses only, so
78
+ // the emitted file is safe to load anywhere.
79
+ function curateLabels(vsPath, symbols) {
80
+ if (!existsSync(vsPath)) return { kept: 0, dropped: 0 };
81
+ const addr = new Set(symbols.filter((s) => s.isAddress && s.used).map((s) => s.name));
82
+ const kept = [];
83
+ let dropped = 0;
84
+ for (const l of readFileSync(vsPath, "utf8").split("\n")) {
85
+ const m = l.match(/^al\s+C:[0-9a-f]+\s+\.(\S+)/i);
86
+ if (!m) continue;
87
+ if (addr.has(m[1])) kept.push(l); else dropped++;
88
+ }
89
+ writeFileSync(vsPath, kept.join("\n") + (kept.length ? "\n" : ""));
90
+ return { kept: kept.length, dropped };
91
+ }
92
+
93
+ function build(src, opts) {
94
+ if (!existsSync(src)) die(`no such source file: ${src}`);
95
+ // Side files follow the .prg, not the source: two -DVARIANT builds of one
96
+ // source must not overwrite each other's symbol tables.
97
+ const prg = opts.out || join(opts.outDir || dirname(src),
98
+ basename(src).replace(/\.(a|asm|s)$/i, "") + ".prg");
99
+ const outDir = dirname(prg);
100
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
101
+ const stem = prg.replace(/\.prg$/i, "");
102
+
103
+ const args = [
104
+ "--cpu", "6510", // C64: enables the 6510 illegal opcodes
105
+ "-f", opts.format || "cbm", // cbm = 2-byte load address, what LOAD wants
106
+ "-Wtype-mismatch", // catches a missing '#' on an immediate
107
+ "--strict-segments", // overlapping segments are reported as errors
108
+ "--msvc", // machine-parseable diagnostics
109
+ "-v1", // report the address range actually emitted
110
+ "-o", prg,
111
+ "-l", `${stem}.sym`,
112
+ "--vicelabels", `${stem}.vs`,
113
+ ];
114
+ if (!opts.noReport) args.push("-r", `${stem}.rep`);
115
+ for (const d of opts.defines) args.push(`-D${d}`);
116
+ for (const i of opts.includes) args.push("-I", i);
117
+ if (opts.setpc) args.push("--setpc", opts.setpc);
118
+ args.push(src);
119
+
120
+ // `<cbm/c64/vic.a>` style includes resolve through the ACME env var, so set
121
+ // it here rather than depending on the shell environment carrying it.
122
+ const env = { ...process.env };
123
+ if (ACME_LIB.path) env.ACME = ACME_LIB.path;
124
+ const r = spawnSync("acme", args, { encoding: "utf8", env });
125
+ if (r.error) {
126
+ die(r.error.code === "ENOENT"
127
+ ? "install the ACME cross assembler and put `acme` on PATH"
128
+ : String(r.error));
129
+ }
130
+
131
+ const diags = parseDiagnostics(((r.stderr || "") + (r.stdout || "")).trim());
132
+ if (diags.some((d) => /ACME.*environment variable/i.test(d.message))) {
133
+ diags.push({
134
+ file: null, line: null, severity: "note", zone: null,
135
+ message: `for <...> includes, set $ACME to the directory holding ${LIB_MARKER} ` +
136
+ `(looked in: ${ACME_LIB.tried.join(", ")})`,
137
+ });
138
+ }
139
+ const errors = diags.filter((d) => d.severity.endsWith("error"));
140
+ const ok = r.status === 0 && existsSync(prg);
141
+
142
+ let range = null, size = null, symbols = [], labels = null;
143
+ if (ok) {
144
+ symbols = parseSymbols(`${stem}.sym`);
145
+ labels = curateLabels(`${stem}.vs`, symbols);
146
+ const buf = readFileSync(prg);
147
+ size = buf.length;
148
+ if ((opts.format || "cbm") === "cbm" && buf.length >= 2) {
149
+ const load = buf[0] | (buf[1] << 8);
150
+ range = { load, end: load + buf.length - 2, bytes: buf.length - 2 };
151
+ }
152
+ }
153
+ return { ok, prg, stem, diags, errors, symbols, labels, range, size };
154
+ }
155
+
156
+ const hex = (n, w = 4) => n.toString(16).padStart(w, "0");
157
+
158
+ function reportBuild(res, { json }) {
159
+ if (json) { console.log(JSON.stringify(res, null, 2)); return; }
160
+ for (const d of res.diags) {
161
+ if (d.file) console.log(`${d.file}:${d.line}: ${d.severity}: ${d.message}`);
162
+ else console.log(` ${d.message}`);
163
+ }
164
+ if (!res.ok) { console.error(`build FAILED (${res.errors.length} error(s))`); return; }
165
+ const r = res.range;
166
+ console.log(
167
+ `built ${res.prg} (${res.size} bytes)` +
168
+ (r ? ` load $${hex(r.load)}-$${hex(r.end)} ${r.bytes} bytes of code` : "")
169
+ );
170
+ const used = res.symbols.filter((s) => s.used).length;
171
+ console.log(`symbols: ${res.stem}.sym (${used} used / ${res.symbols.length} total)`);
172
+ if (res.labels) {
173
+ console.log(`debug labels: ${res.stem}.vs (${res.labels.kept} addresses)`);
174
+ }
175
+ }
176
+
177
+ // -------------------------------------------------------------------- verbs
178
+
179
+ function cmdBuild(argv) {
180
+ const o = parseOpts(argv);
181
+ const res = build(o.src, o);
182
+ reportBuild(res, o);
183
+ process.exit(res.ok ? 0 : 1);
184
+ }
185
+
186
+ function cmdSym(argv) {
187
+ const o = parseOpts(argv);
188
+ const res = build(o.src, { ...o, noReport: true });
189
+ if (!res.ok) { reportBuild(res, o); process.exit(1); }
190
+ const used = res.symbols.filter((s) => s.used).sort((a, b) => a.name.localeCompare(b.name));
191
+ if (o.json) { console.log(JSON.stringify(used, null, 2)); return; }
192
+ for (const s of used) console.log(`${s.isAddress ? "addr " : "const"} ${s.value.padStart(6)} ${s.name}`);
193
+ }
194
+
195
+ // A skeleton that is correct on the first try: BASIC stub with a computed SYS
196
+ // target, the C64 symbol libraries, and no !to (the CLI supplies -o).
197
+ function cmdNew(argv) {
198
+ const path = argv[0];
199
+ if (!path) die("usage: new <file.a>");
200
+ if (existsSync(path)) die(`${path} already exists`);
201
+ // template.a lives at the skill root, one level up from scripts/, by
202
+ // decision (D-03): only .mjs modules move into scripts/.
203
+ writeFileSync(path, readFileSync(join(HERE, "..", "template.a"), "utf8"));
204
+ console.log(`wrote ${path}`);
205
+ console.log(`next: node ${selfPath()} build ${path}`);
206
+ }
207
+
208
+ // `toacme` ships with ACME and turns object code back into ACME source.
209
+ function cmdDisasm(argv) {
210
+ const src = argv[0];
211
+ if (!src) die("usage: disasm <file.prg> [out.a]");
212
+ const out = argv[1] || src.replace(/\.prg$/i, "") + ".dis.a";
213
+ const r = spawnSync("toacme", ["object", src, out], { encoding: "utf8" });
214
+ if (r.error) die("install the ACME cross assembler and put `toacme` on PATH");
215
+ if (r.status !== 0) die(`toacme: ${(r.stderr || r.stdout).trim()}`);
216
+ const n = readFileSync(out, "utf8").split("\n").filter((l) => /^L[0-9a-f]{4}/.test(l)).length;
217
+ console.log(`${out}: ${n} lines`);
218
+ console.log("Read it as a linear decode: trust the instruction stream, and");
219
+ console.log("treat strings, tables and the BASIC stub as data. To reassemble,");
220
+ console.log("define the out-of-range labels it emits (Ld020, Lffd2, ...) and");
221
+ console.log("indent its illegal-opcode lines to the operand column.");
222
+ }
223
+
224
+ // ------------------------------------------------------------------ options
225
+
226
+ function parseOpts(argv) {
227
+ const o = { defines: [], includes: [], json: false };
228
+ const rest = [];
229
+ for (let i = 0; i < argv.length; i++) {
230
+ const a = argv[i];
231
+ if (a === "--json") o.json = true;
232
+ else if (a === "--no-report") o.noReport = true;
233
+ else if (a === "-o" || a === "--out") o.out = argv[++i];
234
+ else if (a === "--out-dir") o.outDir = argv[++i];
235
+ else if (a === "-f" || a === "--format") o.format = argv[++i];
236
+ else if (a === "--setpc") o.setpc = argv[++i];
237
+ else if (a === "-D") o.defines.push(argv[++i]);
238
+ else if (a.startsWith("-D")) o.defines.push(a.slice(2));
239
+ else if (a === "-I") o.includes.push(argv[++i]);
240
+ else rest.push(a);
241
+ }
242
+ o.src = rest[0];
243
+ if (!o.src) die("no source file given");
244
+ return o;
245
+ }
246
+
247
+ // --------------------------------------------------------------------- main
248
+
249
+ const [cmd, ...rest] = process.argv.slice(2);
250
+ const VERBS = { new: cmdNew, build: cmdBuild, sym: cmdSym, disasm: cmdDisasm };
251
+ if (!cmd || !VERBS[cmd]) {
252
+ console.log(`usage: node ${selfPath()} <command> [options]
253
+
254
+ new <file.a> scaffold a C64 program (BASIC stub + libs)
255
+ build <file.a> assemble -> .prg .sym .vs .rep
256
+ sym <file.a> list the symbols the program uses
257
+ disasm <file.prg> [out.a] turn object code back into ACME source
258
+
259
+ options: -o FILE --out-dir DIR -f FORMAT --setpc ADDR -DSYM=VAL -I DIR
260
+ --no-report --json`);
261
+ process.exit(cmd ? 1 : 0);
262
+ }
263
+ VERBS[cmd](rest);
@@ -0,0 +1,39 @@
1
+ ; C64 / 6510 program skeleton.
2
+ ; Build: node .claude/skills/acme-build/scripts/acme.mjs build THIS.a
3
+ ; No !to here on purpose - the driver passes -o, and having both makes ACME
4
+ ; warn "Output file already chosen" and silently ignore the !to.
5
+
6
+ !cpu 6510 ; C64's CPU: legal 6502 + the illegal opcodes
7
+ !source <cbm/c64/vic.a> ; vic_* registers, viccolor_* constants
8
+ !source <cbm/c64/kernal.a> ; k_* KERNAL entry points ($ff81-$fff5)
9
+ !source <cbm/c64/cia1.a> ; cia1_* keyboard / joystick 2
10
+
11
+ * = $0801 ; start of BASIC RAM
12
+
13
+ ; --- BASIC stub: "10 SYS <entry>", so LOAD"...",8,1 + RUN reaches the code.
14
+ ; The SYS digits are computed from `entry`, so inserting code above never
15
+ ; breaks the stub - which is the classic way to lose an afternoon.
16
+ !word .eol, 10 ; link to next line, line number
17
+ !byte $9e ; SYS token
18
+ !byte '0' + entry % 10000 / 1000
19
+ !byte '0' + entry % 1000 / 100
20
+ !byte '0' + entry % 100 / 10
21
+ !byte '0' + entry % 10
22
+ !byte 0 ; end of BASIC line
23
+ .eol !word 0 ; end of BASIC program
24
+
25
+ entry
26
+ lda #viccolor_BLACK
27
+ sta vic_cbg ; $d021 background
28
+ lda #viccolor_GREEN
29
+ sta vic_cborder ; $d020 border
30
+
31
+ ldx #0
32
+ - lda .msg,x
33
+ beq +
34
+ jsr k_chrout ; $ffd2 print one PETSCII char
35
+ inx
36
+ bne -
37
+ + rts
38
+
39
+ .msg !pet "hello from acme", 13, 0
@@ -0,0 +1,199 @@
1
+ ---
2
+ name: c64-memory-mapping
3
+ description: Look up what any C64 address means and turn raw 6502 disassembly into documented assembly, by resolving every address against the C64 memory map, KERNAL ROM routine list, canonical assembler symbols, and per-bit VIC-II/SID/CIA register tables. Use when asked to annotate or comment assembly, document a disassembly listing, or look up an address like $D020, $EA24 or $FFD2.
4
+ ---
5
+
6
+ # C64 memory mapping & annotated disassembly
7
+
8
+ Look up what a C64 address means, and document a 6502 listing by resolving every
9
+ address it touches. One script does both, offline, anywhere Node ≥18 runs:
10
+
11
+ ```bash
12
+ D=.claude/skills/c64-memory-mapping/scripts/driver.mjs # relative to the repo root
13
+
14
+ node $D lookup '$D011' '$FFD2' # what lives at an address
15
+ node $D annotate --file game.asm # document a listing or .asm file
16
+ … | node $D annotate # ... or one piped in on stdin
17
+ node $D memmap # refresh the table from its sources (needs network)
18
+ ```
19
+
20
+ Write an address however the source you copied it from wrote it — hex `$D011`,
21
+ `0xD011`, `D011h` or `D011`, binary `%1101000000010001`, or decimal `53265` all
22
+ reach the same register, in either letter case.
23
+
24
+ ## Look up an address
25
+
26
+ `lookup` prints the full published prose for an address, most specific match
27
+ first, each tagged with the source it came from and the memory region it sits in.
28
+ Registers carry their per-bit breakdown, and the wider regions enclosing the
29
+ address follow it:
30
+
31
+ ```
32
+ === $D011 ===
33
+ $D011 [$D000-$D3FF, 53248-54271 VIC-II; video display] <sta>
34
+ Screen control register #1. Bits: Bits #0-#2: Vertical raster scroll. Bit #3: Screen height; 0 =
35
+ 24 rows; 1 = 25 rows. Bit #4: 0 = Screen off, complete screen is covered by border; 1 = Screen
36
+ on, normal screen contents are visible. Bit #5: 0 = Text mode; 1 = Bitmap mode. Bit #6: 1 =
37
+ Extended background mode on. Bit #7: Read: Current raster line (bit #8). Write: Raster line to
38
+ generate interrupt at (bit #8). Default: $1B, %00011011.
39
+ $D011 [MOS 6566 VIDEO INTERFACE CONTROLLER (VIC)] <io>
40
+ VIC Control Register
41
+ bit 7 Raster Compare: (Bit 8) See 53266
42
+ bit 6 Extended Color Text Mode 1 = Enable
43
+ bit 5 Bit Map Mode. 1 = Enable
44
+ bit 4 Blank Screen to Border Color: O = Blank
45
+ bit 3 Select 24/25 Row Text Display: 1 = 25 Rows
46
+ bit 2-0 Smooth Scroll to Y Dot-Position (0-7)
47
+ $D000-$D02E [C64 memory map (labelled)] <zim>
48
+ 6566 Video Interface Chip, VIC II.
49
+
50
+ ```
51
+
52
+ Reach for this whenever a bare address needs a meaning — a register you are about
53
+ to write, a `JSR` target, or a symbol name to give a variable.
54
+
55
+ ## Annotate a listing
56
+
57
+ ```bash
58
+ node $D annotate --file game.asm --out game.documented.asm
59
+ ```
60
+
61
+ The input is any text carrying 6502 mnemonics: hand-written source, or a listing
62
+ from whichever disassembler produced it. Lines come back byte-for-byte —
63
+ indentation, labels, directives, blank lines and existing comments intact — with
64
+ a `; $addr (SYMBOL) = description` comment appended:
65
+
66
+ ```
67
+ * = $C000
68
+ start lda #$00
69
+ sta $d021 ; $D021 = Background color (only bits #0-#3)
70
+ loop: ldx $dc01 ; $DC01 = Port B, keyboard matrix rows and joystick #1
71
+ inc $d020 ; $D020 = Border color (only bits #0-#3)
72
+ jsr $ffd2 ; $FFD2 = Output Vector, chrout
73
+ lda ($fb),y ; $00FB-$00FE (FREKZP, pointer) = Unused (4 bytes)
74
+ sta $0400,x ; $0400-$07E7 (VICSCN, indexed) = Default area of screen memory (1000 bytes)
75
+ bne loop
76
+ jmp ($0314) ; $0314-$0315 (CINV, pointer) = Execution address of interrupt service routine
77
+ rts
78
+ ```
79
+
80
+ A header block listing every referenced address with its full description, symbol
81
+ and region is prepended (elided above): measured on the two examples on this page,
82
+ the eleven-line listing above grows a 23-line header and the nine-line IRQ excerpt
83
+ below grows a 25-line one — a little over 2x the input either way. Pass
84
+ `--no-header` to drop it and get the annotated body alone.
85
+
86
+ Options:
87
+
88
+ - `--out FILE` writes the result to a file instead of stdout.
89
+ - `--max-span N` keeps comments to hits narrower than N bytes, for terser output.
90
+ Default is 4096 bytes: at that width a hit as wide as screen RAM
91
+ (`$0400-$07E7`, 1000 bytes) or the `$C000-$CFFF` block still earns an inline
92
+ comment, while the 8 KB BASIC and KERNAL ROM blocks do not — a branch
93
+ annotated "KERNAL ROM (8192 bytes)" teaches nobody anything. The cap applies
94
+ only to non-flow instructions: flow instructions (`JMP`, `JSR`, branches,
95
+ `RTS`, `RTI`) are held to 2 bytes regardless of `--max-span`, so a jump or
96
+ branch only earns a comment when it targets a specific vector such as
97
+ `$0314`. `--max-span 2` gives register- and variable-level comments only.
98
+ - `--no-header` suppresses the prepended header block, for piping annotated
99
+ output straight into a file across a large listing set.
100
+ - `--file -` reads stdin, identical to a bare `annotate`.
101
+
102
+ Piping works the same way, to annotate straight from another command:
103
+
104
+ ```bash
105
+ node $D annotate --max-span 2 < irq.txt
106
+ ```
107
+
108
+ ```
109
+ $EA31: 20 EA FF JSR $FFEA ; $FFEA = Increment Real-Time Clock
110
+ $EA34: A5 CC LDA $CC ; $00CC (BLNSW) = Cursor visibility switch
111
+ $EA36: D0 29 BNE $EA61
112
+ $EA38: C6 CD DEC $CD ; $00CD (BLNCT) = Delay counter for changing cursor phase
113
+ $EA3C: A9 14 LDA #$14
114
+ $EA40: A4 D3 LDY $D3 ; $00D3 (PNTR) = Current cursor column
115
+ $EA44: AE 87 02 LDX $0287 ; $0287 (GDCOL) = Color of character under cursor
116
+ $EA47: B1 D1 LDA ($D1),Y ; $00D1-$00D2 (PNT, pointer) = Pointer to current line in screen memory
117
+ $EA4F: 20 24 EA JSR $EA24 ; $EA24 = Syncronise Color Pointer
118
+ ```
119
+
120
+ ## Reading the annotations
121
+
122
+ Weigh a comment by the region it describes:
123
+
124
+ - **`$D000-$DFFF` is authoritative.** Hardware decides what these mean, so a VIC,
125
+ SID or CIA comment holds for any program. `LDA $DC01 / AND #$10 / BNE` annotates
126
+ as "Port B, keyboard matrix rows and joystick #1", bit 4 is the fire button, and
127
+ that reading is sound because it rests on hardware. KERNAL entry points are
128
+ equally solid wherever ROM is banked in — `$01` bits #0-#2 select it, and the
129
+ vector at `$0314` shows whether the KERNAL IRQ path is in use.
130
+ - **Zero page, `$0200-$07FF` and the BASIC area describe BASIC and KERNAL usage.**
131
+ Read them as a strong hint and confirm against the program's own behaviour
132
+ before adopting the name. A game that banks ROM out keeps its own variables
133
+ there. A real case: at `$08E6` a game's `LDA $49` gets labelled `FORPNT`
134
+ ("value of current variable during LET"), when `$49` is really one of that
135
+ game's own variables. Take the address, verify the meaning.
136
+ - **A region-only answer is not an error.** An address that no source names
137
+ specifically prints only the wider regions enclosing it — no error, no
138
+ not-found line. `node $D lookup '$1234'` prints:
139
+ ```
140
+ === $1234 ===
141
+ $0801-$9FFF [$0800-$9FFF, 2048-40959 BASIC area] <sta>
142
+ Default BASIC area (38911 bytes).
143
+ $0800-$9FFF [C64 memory map (labelled)] <zim>
144
+ Normal BASIC Program space.
145
+ ```
146
+ This is the dominant case for any game's own code: a region-only answer
147
+ means "the four tables do not name this exact address", not "this address is
148
+ unmapped". It differs from the genuine zero-hit case, where `lookup` prints
149
+ `(not in memory map)` because nothing at all covers the address
150
+ (driver.mjs:485-487). Checked directly against the committed table (a scan of
151
+ `memmap.json`, not a `memmap` rebuild): every address `$0000`-`$FFFF` is
152
+ covered by at least one entry as of this build, so `(not in memory map)` is
153
+ not reachable for any valid address today — the branch exists for a future
154
+ table that loses coverage, not for anything the current one omits.
155
+
156
+ For a game, the fastest route to a real name is to annotate with `--max-span 2`,
157
+ trust the I/O lines immediately, and confirm the rest by watching what the code
158
+ does with them.
159
+
160
+ ## Where the data comes from
161
+
162
+ `node $D memmap` rebuilds the table from four published sources, each covering
163
+ what the others leave out:
164
+
165
+ | Source | Contribution |
166
+ |---|---|
167
+ | [sta.c64.org/cbm64mem.html](https://sta.c64.org/cbm64mem.html) | richest prose for zero page, work areas, screen RAM, I/O |
168
+ | [C64.MemoryMap.txt](https://www.zimmers.net/anonftp/pub/cbm/maps/C64.MemoryMap.txt) | canonical assembler symbols — `PNT`, `CINV`, `VICSCN`, `TXTTAB` |
169
+ | [krnromma.htm](http://unusedino.de/ec64/technical/aay/c64/krnromma.htm) | every KERNAL ROM routine by name, so `JSR $EA24` reads as "Syncronise Color Pointer" |
170
+ | [C64io.txt](https://www.zimmers.net/anonftp/pub/cbm/maps/C64io.txt) | VIC/SID/CIA registers broken down per bit |
171
+
172
+ The built table is committed alongside the script, so `lookup` and `annotate` need
173
+ only Node. Rebuild when a source publishes a correction; `lookup`'s `<src>` tags
174
+ show which table any given claim came from.
175
+
176
+ `memmap` overwrites the committed, git-tracked `memmap.json` (driver.mjs:270 —
177
+ 235,925 bytes as of 2026-08-04, confirm with `wc -c`) **in place, with no backup
178
+ and no diff.** One of the four sources (`http://unusedino.de/…`, driver.mjs:33)
179
+ is fetched over plain HTTP with no TLS, and the only guard against a bad rebuild
180
+ is a per-source emptiness check plus a 600-entry floor across all sources
181
+ combined (driver.mjs:262, 268) — so a partially-reachable or partially-changed
182
+ source set can silently replace good tracked data with less of it. Rebuild, then
183
+ run `git diff --stat` on `memmap.json` before accepting the result, and
184
+ `git checkout` the file if the diff is not explainable as the correction you
185
+ were expecting. Because it mutates the repo, `memmap` belongs behind a GSD
186
+ command (`/gsd-quick`), per this project's GSD Workflow Enforcement rule — it is
187
+ not a read-only lookup like `lookup` and `annotate`.
188
+
189
+ ## Troubleshooting
190
+
191
+ | Symptom | Fix |
192
+ |---|---|
193
+ | Comments land on regions too wide to be useful | `--max-span 2`; the default is 4096 bytes. |
194
+ | A `JSR` or branch target got no comment at all | Flow instructions are capped at 2 bytes regardless of `--max-span`; `lookup` the target directly for the ROM routine name. |
195
+ | The output is mostly header | `--no-header`. |
196
+ | `lookup` printed only wide region lines and no specific name | Nothing in the four tables names that address; expected for the game's own code — take the region and name the address from what the code does with it. |
197
+ | `lookup` printed `(not in memory map)` | Nothing covers it; check the address parsed as intended, since a bare `1234` reads as decimal. |
198
+ | `no memmap.json; run: node driver.mjs memmap` | Restore the committed table with `git checkout` rather than rebuilding; the rebuild needs network and overwrites tracked data. |
199
+ | `memmap` rewrote `memmap.json` and the diff is large or negative | A source was unreachable or changed; `git checkout` the file. |