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