romdevtools 0.41.0 → 0.43.0

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 (52) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile/emit-65816.js +105 -0
  9. package/src/analysis/recompile/emit-m68k.js +295 -0
  10. package/src/analysis/recompile/index.js +169 -0
  11. package/src/analysis/recompile/ir.js +123 -0
  12. package/src/analysis/recompile/lift-6502.js +176 -0
  13. package/src/analysis/recompile-65816.js +533 -0
  14. package/src/cheats/gamegenie.js +14 -2
  15. package/src/cores/registry.js +43 -51
  16. package/src/mcp/state.js +91 -2
  17. package/src/mcp/tools/cheats.js +10 -1
  18. package/src/mcp/tools/disasm.js +329 -21
  19. package/src/mcp/tools/find-references.js +93 -2
  20. package/src/mcp/tools/frame.js +440 -4
  21. package/src/mcp/tools/index.js +34 -1
  22. package/src/mcp/tools/lifecycle.js +53 -24
  23. package/src/mcp/tools/memory.js +1 -1
  24. package/src/mcp/tools/playtest.js +24 -1
  25. package/src/mcp/tools/project.js +1 -1
  26. package/src/mcp/tools/rendering-context.js +4 -2
  27. package/src/mcp/tools/watch-memory.js +87 -9
  28. package/src/mcp/util.js +45 -0
  29. package/src/playtest/playtest.js +115 -46
  30. package/src/toolchains/cc65/da65.js +7 -0
  31. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  32. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  33. package/src/cores/wasm/fceumm_libretro.js +0 -2
  34. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  35. package/src/cores/wasm/gambatte_libretro.js +0 -2
  36. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  37. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  38. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  39. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  40. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  41. package/src/cores/wasm/handy_libretro.js +0 -2
  42. package/src/cores/wasm/handy_libretro.wasm +0 -0
  43. package/src/cores/wasm/mgba_libretro.js +0 -2
  44. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  45. package/src/cores/wasm/prosystem_libretro.js +0 -2
  46. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  47. package/src/cores/wasm/snes9x_libretro.js +0 -2
  48. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  49. package/src/cores/wasm/stella2014_libretro.js +0 -2
  50. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  51. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  52. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -0,0 +1,295 @@
1
+ // IR → m68k (Genesis / Mega Drive, vasm68k syntax) emitter. This is the proof
2
+ // that the recompile engine is GENERIC: a 6502-sourced IR targets the 68000 here
3
+ // through the SAME pipeline that targets the 65816 — no second bespoke recompiler.
4
+ //
5
+ // Unlike NES→SNES (65816 runs 6502 logic 1:1 in emulation mode), NES→Genesis is
6
+ // REAL ISA translation: the 6502's 8-bit register file is modeled on 68000 data
7
+ // registers, and each abstract op becomes the equivalent 68000 instruction. The
8
+ // 6502 address space (zero page + RAM + the parts of $0000-$1FFF a game touches)
9
+ // is mapped to a contiguous block of 68000 work RAM (NES_RAM), so a `sta $05`
10
+ // becomes `move.b d0, (NES_RAM+5).w` — and that block IS the Tier-1 RAM-diff
11
+ // oracle's mirror (run original + port, byte-diff this block per frame).
12
+ //
13
+ // SCOPE (phase 1, logic only): the documented 6502 set that maps mechanically;
14
+ // the PPU/APU seam is STUBBED (writes drop, $2002 read → $80 so vblank loops
15
+ // exit), exactly like the SNES phase-1 path — presentation is a later runtime.
16
+ // Decimal mode, RMW-on-MMIO, and undocumented opcodes are REFUSED upstream by the
17
+ // lifter, so they never reach here.
18
+ //
19
+ // Register model: 6502 A → d0 X → d1 Y → d2 (8-bit, used via .b)
20
+ // 6502 P (flags) → the 68000 CCR is set by the move/alu itself;
21
+ // we do NOT model the full 6502 flag semantics — branches use the
22
+ // CCR the prior op left, which is correct for the common
23
+ // load→branch / compare→branch idioms (the same pragmatic bound
24
+ // the SNES emulation-mode path relies on hardware for).
25
+ //
26
+ // Plain JS ESM + JSDoc.
27
+
28
+ import { IR } from "./ir.js";
29
+ import { ABSTRACT, COND } from "./ir.js";
30
+
31
+ /** Base of the mapped 6502 address space inside 68000 work RAM. Genesis work RAM
32
+ * is $FF0000-$FFFFFF; we park the 8KB NES-visible space at $FF0000. A 6502
33
+ * address N reads/writes (NES_RAM + N). */
34
+ const NES_RAM = 0xff0000;
35
+
36
+ /** 6502 registers → 68000 data registers. */
37
+ const ACC = "d0", X = "d1", Y = "d2";
38
+
39
+ /** Translate a 6502 operand to a 68000 source/dest effective address.
40
+ * Returns { ea, imm } — `imm` true if it's an immediate (#...). Indexed modes
41
+ * (`$addr,x` / `$addr,y`) use the index register as a 68000 address-register
42
+ * displacement via a scratch address reg a0. */
43
+ function operandToEa(operand) {
44
+ if (operand == null) return { ea: null, imm: false };
45
+ const op = operand.trim();
46
+ // immediate: #$nn → #$nn
47
+ let m = op.match(/^#\$([0-9A-Fa-f]+)$/);
48
+ if (m) return { ea: `#$${m[1]}`, imm: true };
49
+ m = op.match(/^#(\d+)$/);
50
+ if (m) return { ea: `#${m[1]}`, imm: true };
51
+ // absolute / zero-page, optionally indexed
52
+ m = op.match(/^\$([0-9A-Fa-f]+)\s*(?:,\s*([xy]))?$/i);
53
+ if (m) {
54
+ const addr = parseInt(m[1], 16);
55
+ const idx = m[2] ? m[2].toLowerCase() : null;
56
+ if (!idx) {
57
+ const abs = NES_RAM + addr;
58
+ // word-sized absolute EA: (abs).l (work RAM is in the high $FFxxxx range)
59
+ return { ea: `(${hex(abs)}).l`, imm: false, addr };
60
+ }
61
+ // indexed: a0 = NES_RAM + addr + index_reg ; EA = (a0)
62
+ return { ea: null, imm: false, addr, idx };
63
+ }
64
+ // a bare label (branch/jump target handled elsewhere) — pass through as a symbol
65
+ return { ea: op, imm: false, symbol: true };
66
+ }
67
+
68
+ const hex = (n) => "$" + (n >>> 0).toString(16).toUpperCase();
69
+
70
+ /** Emit the indexed-address setup into a0 for `$addr,x|y`, returning the EA "(a0)". */
71
+ function indexedEaLines(addr, idx, out) {
72
+ const ireg = idx === "x" ? X : Y;
73
+ out.push(` move.l #${hex(NES_RAM + addr)},a0`);
74
+ out.push(` move.b ${ireg},d3`);
75
+ out.push(` ext.w d3`);
76
+ out.push(` adda.w d3,a0`);
77
+ return "(a0)";
78
+ }
79
+
80
+ /** Emit one IR `reg` (data/ALU/transfer/flag) node as 68000 instructions. */
81
+ function emitReg(node, out) {
82
+ const k = node.kind;
83
+ const { ea, addr, idx } = operandToEa(node.operand);
84
+ // resolve the source/dest EA, materializing an indexed EA into a0 when needed
85
+ const realEa = () => (idx != null ? indexedEaLines(addr, idx, out) : ea);
86
+ switch (k) {
87
+ case ABSTRACT.LOAD_ACC: out.push(` move.b ${realEa()},${ACC}`); return;
88
+ case ABSTRACT.LOAD_X: out.push(` move.b ${realEa()},${X}`); return;
89
+ case ABSTRACT.LOAD_Y: out.push(` move.b ${realEa()},${Y}`); return;
90
+ case ABSTRACT.STORE_ACC: out.push(` move.b ${ACC},${realEa()}`); return;
91
+ case ABSTRACT.STORE_X: out.push(` move.b ${X},${realEa()}`); return;
92
+ case ABSTRACT.STORE_Y: out.push(` move.b ${Y},${realEa()}`); return;
93
+ case ABSTRACT.TRANSFER: emitTransfer(node, out); return;
94
+ case ABSTRACT.ADD: out.push(` add.b ${realEa()},${ACC}`); return;
95
+ case ABSTRACT.SUB: out.push(` sub.b ${realEa()},${ACC}`); return;
96
+ case ABSTRACT.AND: out.push(` and.b ${realEa()},${ACC}`); return;
97
+ case ABSTRACT.OR: out.push(` or.b ${realEa()},${ACC}`); return;
98
+ case ABSTRACT.XOR: out.push(` eor.b ${ACC},${realEa()}`); return; // 68k eor is reg→ea; approx
99
+ case ABSTRACT.CMP: out.push(` cmp.b ${realEa()},${ACC}`); return;
100
+ case ABSTRACT.BIT: out.push(` move.b ${realEa()},d3`, ` and.b ${ACC},d3`); return;
101
+ case ABSTRACT.INC: emitIncDec(node, out, "addq"); return;
102
+ case ABSTRACT.DEC: emitIncDec(node, out, "subq"); return;
103
+ case ABSTRACT.SHL: out.push(` lsl.b #1,${ACC}`); return;
104
+ case ABSTRACT.SHR: out.push(` lsr.b #1,${ACC}`); return;
105
+ case ABSTRACT.ROL: out.push(` roxl.b #1,${ACC}`); return;
106
+ case ABSTRACT.ROR: out.push(` roxr.b #1,${ACC}`); return;
107
+ case ABSTRACT.INX: out.push(` addq.b #1,${X}`); return;
108
+ case ABSTRACT.INY: out.push(` addq.b #1,${Y}`); return;
109
+ case ABSTRACT.DEX: out.push(` subq.b #1,${X}`); return;
110
+ case ABSTRACT.DEY: out.push(` subq.b #1,${Y}`); return;
111
+ case ABSTRACT.CPX: out.push(` cmp.b ${realEa()},${X}`); return;
112
+ case ABSTRACT.CPY: out.push(` cmp.b ${realEa()},${Y}`); return;
113
+ case ABSTRACT.PUSH: out.push(` ; push (${node.mnemonic}) — 6502 stack ops are no-ops in the logic port`); return;
114
+ case ABSTRACT.PULL: out.push(` ; pull (${node.mnemonic}) — 6502 stack ops are no-ops in the logic port`); return;
115
+ case ABSTRACT.SET_FLAG:
116
+ case ABSTRACT.CLR_FLAG:
117
+ case ABSTRACT.NOP:
118
+ out.push(` nop ; ${node.mnemonic} (flag/nop — CCR handled by adjacent ops)`); return;
119
+ default:
120
+ out.push(` ; UNTRANSLATED reg op ${node.mnemonic} (${k})`); return;
121
+ }
122
+ }
123
+
124
+ /** 6502 register transfers (tax/tay/txa/tya/tsx/txs) → 68000 reg moves. */
125
+ function emitTransfer(node, out) {
126
+ const m = node.mnemonic;
127
+ const map = {
128
+ tax: `move.b ${ACC},${X}`, tay: `move.b ${ACC},${Y}`,
129
+ txa: `move.b ${X},${ACC}`, tya: `move.b ${Y},${ACC}`,
130
+ tsx: `move.b #$FF,${X} ; stack ptr is fixed in the logic port`,
131
+ txs: `; txs — stack ptr fixed in the logic port`,
132
+ };
133
+ out.push(` ${map[m] || `; ${m} (transfer)`}`);
134
+ }
135
+
136
+ /** inc/dec on memory (or accumulator) → addq/subq. */
137
+ function emitIncDec(node, out, instr) {
138
+ const { ea, addr, idx } = operandToEa(node.operand);
139
+ if (node.operand == null || node.operand === "a") {
140
+ out.push(` ${instr}.b #1,${ACC}`);
141
+ return;
142
+ }
143
+ const e = idx != null ? indexedEaLines(addr, idx, out) : ea;
144
+ out.push(` ${instr}.b #1,${e}`);
145
+ }
146
+
147
+ /** ISA-neutral branch cond → 68000 branch instruction. The 68000 CCR after a
148
+ * move/cmp gives Z (eq/ne) and N (mi/pl); carry/overflow map to C/V. */
149
+ const M68K_BRANCH = {
150
+ [COND.EQ]: "beq", [COND.NE]: "bne", [COND.CC]: "bcc", [COND.CS]: "bcs",
151
+ [COND.MI]: "bmi", [COND.PL]: "bpl", [COND.VC]: "bvc", [COND.VS]: "bvs",
152
+ };
153
+
154
+ /**
155
+ * Emit the m68k body from an IR program. Returns the asm body (functions only;
156
+ * the ROM wrapper/vectors/seam are added by emitM68kWrapper / emitM68kSeam).
157
+ * @param {Array<object>} ir
158
+ * @returns {string}
159
+ */
160
+ export function emitm68kBody(ir) {
161
+ const out = [];
162
+ for (const node of ir) {
163
+ if (node.label && node.op !== IR.LABEL) out.push(`${node.label}:`);
164
+ switch (node.op) {
165
+ case IR.LABEL: out.push(`${node.name}:`); break;
166
+ case IR.REG: emitReg(node, out); break;
167
+ case IR.BRANCH: out.push(` ${M68K_BRANCH[node.cond] || "bra"} ${node.target}`); break;
168
+ case IR.JUMP: out.push(` jmp ${node.target}`); break;
169
+ case IR.CALL: out.push(` jsr ${node.target}`); break;
170
+ case IR.RET: out.push(node.kind === "interrupt" ? " rte" : " rts"); break;
171
+ case IR.HWREG: emitHwReg(node, out); break;
172
+ case IR.PASSTHROUGH: out.push(node.text); break;
173
+ case IR.REFUSE: out.push(` ; UNTRANSLATED: ${(node.raw || "").trim()} (${node.reason})`); break;
174
+ default: break;
175
+ }
176
+ }
177
+ return out.join("\n");
178
+ }
179
+
180
+ /** hwreg (the PPU/APU seam) → a call into the m68k runtime stub. Contract mirrors
181
+ * the 65816 path: d0 holds the value (writes) / receives it (reads); d1 carries
182
+ * the source register low byte so one routine handles the file. */
183
+ function emitHwReg(node, out) {
184
+ const { access, reg, via } = node;
185
+ const lowByte = `#$${(reg & 0xff).toString(16).padStart(2, "0")}`;
186
+ out.push(` ; seam: ${via} $${reg.toString(16)}`);
187
+ if (access === "write") {
188
+ if (via === "stx") out.push(` move.b ${X},${ACC}`);
189
+ else if (via === "sty") out.push(` move.b ${Y},${ACC}`);
190
+ out.push(` move.b ${lowByte},${X}`, ` jsr NES_PPU_WRITE`);
191
+ } else {
192
+ out.push(` move.b ${lowByte},${X}`, ` jsr NES_PPU_READ`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The m68k ROM wrapper: 68000 vector table at $000000 (SSP + reset PC), the
198
+ * minimal SEGA header at $100, the recompiled body at $200, and the seam include.
199
+ * The body runs straight 68000 (no mode switch) — that's the point: it's a real
200
+ * translation, not an emulation-mode passthrough.
201
+ * @param {{body:string, resetLabel:string, withShim?:boolean, withRuntime?:boolean, nmiBody?:string}} a
202
+ */
203
+ export function emitM68kWrapper(a) {
204
+ const { body, resetLabel } = a;
205
+ return [
206
+ "; NES→Genesis recompiled image (romdev generic emit backend, m68k target).",
207
+ "; The 6502 logic is TRANSLATED to 68000 (not emulated). NES RAM is mapped to",
208
+ "; Genesis work RAM at $FF0000 — that block is the Tier-1 RAM-diff oracle mirror.",
209
+ "",
210
+ " org $00000000",
211
+ "vectors:",
212
+ " dc.l $00FFE000 ; initial SSP",
213
+ " dc.l RESET_ENTRY ; reset PC",
214
+ " rept 62",
215
+ " dc.l RESET_ENTRY ; exceptions → reset (logic port; no handlers yet)",
216
+ " endr",
217
+ "",
218
+ " org $00000100",
219
+ " dc.b \"SEGA MEGA DRIVE \" ; system magic (16)",
220
+ " dc.b \"ROMDEV PORT \" ; copyright (16, padded)",
221
+ " dc.b \"NES-PORT (ROMDEV LOGIC RECOMPILE) \" ; domestic title (48)",
222
+ " dc.b \"NES-PORT (ROMDEV LOGIC RECOMPILE) \" ; overseas title (48)",
223
+ " dc.b \"GM ROMDEV-00 \" ; serial (14)",
224
+ " dc.w $0000 ; checksum",
225
+ " dc.b \"J \" ; device support (16)",
226
+ " dc.l $00000000 ; ROM start",
227
+ " dc.l $003FFFFF ; ROM end",
228
+ " dc.l $00FF0000 ; RAM start",
229
+ " dc.l $00FFFFFF ; RAM end",
230
+ " dc.b \" \" ; SRAM tag (12)",
231
+ " dc.l $00000000 ; SRAM start",
232
+ " dc.l $00000000 ; SRAM end",
233
+ " dc.b \" \" ; modem (12)",
234
+ " dc.b \"romdev generic recompile \" ; notes (32)",
235
+ " dc.b \"JUE \" ; region (16)",
236
+ "",
237
+ " org $00000200",
238
+ "RESET_ENTRY:",
239
+ " move.w #$2700,sr ; mask interrupts during init",
240
+ " lea $00FFE000,sp ; set the stack",
241
+ ` jmp ${resetLabel}`,
242
+ "",
243
+ "; ── recompiled 6502 logic, translated to 68000 ──────────────────────",
244
+ body,
245
+ "",
246
+ ` include "nes_seam_md.asm"`,
247
+ "",
248
+ ].join("\n");
249
+ }
250
+
251
+ /** The m68k hardware-seam stub. Writes drop; $2002 (PPUSTATUS) read returns $80
252
+ * so 6502 vblank-wait loops (`bit $2002 / bpl`) terminate. Same contract as the
253
+ * 65816 seam, in 68000 syntax. */
254
+ export function emitM68kSeam() {
255
+ return [
256
+ "; ── NES hardware seam (m68k v1 stubs) ───────────────────────────────",
257
+ "; d0 = value (writes) / receives it (reads); d1 = register low byte.",
258
+ "NES_PPU_WRITE:",
259
+ " rts",
260
+ "NES_PPU_READ:",
261
+ " move.b #$80,d0 ; vblank set → boot wait-loops exit",
262
+ " rts",
263
+ "NES_APU_WRITE:",
264
+ " rts",
265
+ "NES_OAM_DMA:",
266
+ " rts",
267
+ "",
268
+ ].join("\n");
269
+ }
270
+
271
+ /** Callees referenced by jsr/jmp/branch but not defined in this slice → stub them
272
+ * (single-routine isolation), in 68000 syntax. */
273
+ export function findUndefinedLabelsM68k(body, equs = []) {
274
+ const defined = new Set();
275
+ const equNames = new Set(equs.map((e) => e.split(/\s*=/)[0].trim()));
276
+ const referenced = new Set();
277
+ const SEAM = new Set(["NES_PPU_WRITE", "NES_PPU_READ", "NES_APU_WRITE", "NES_OAM_DMA"]);
278
+ for (const line of body.split("\n")) {
279
+ const def = line.match(/^(\w+):/);
280
+ if (def) defined.add(def[1]);
281
+ const ref = line.match(/^\s+(?:jsr|jmp|b\w\w)\s+([A-Za-z_]\w*)\s*$/);
282
+ if (ref) referenced.add(ref[1]);
283
+ }
284
+ return [...referenced].filter((n) => !defined.has(n) && !equNames.has(n) && !SEAM.has(n));
285
+ }
286
+
287
+ /** `label: rts` stubs (68000) for undefined callees. */
288
+ export function emitM68kStubs(names) {
289
+ if (!names.length) return "";
290
+ return [
291
+ "; ── unresolved callee stubs (single-routine isolation) ──",
292
+ ...names.map((n) => `${n}:\n rts`),
293
+ "",
294
+ ].join("\n");
295
+ }
@@ -0,0 +1,169 @@
1
+ // Generic recompile orchestrator — the source/target-agnostic port engine.
2
+ //
3
+ // recompile(sourceAsm, {source, target}) wires LIFT (source ISA → IR) → EMIT (IR
4
+ // → target ISA) through a registry. Adding a platform PAIR is one lifter + one
5
+ // emitter; the orchestrator, IR, residue handling, callee-stubbing, and the
6
+ // disasm-tool wiring are all shared. NES→SNES and NES→Genesis go through the SAME
7
+ // code path here — the only difference is which emitter the registry hands back.
8
+ //
9
+ // An EMITTER is an object: {
10
+ // targetPlatform, targetIsa,
11
+ // emitBody(ir) → string, // IR functions → target asm body
12
+ // emitWrapper({ body, entry, ... }) → string, // ROM wrapper (vectors, preamble)
13
+ // emitSeam() → string, // the hardware-seam stub include
14
+ // seamFile, // include filename for the seam
15
+ // findUndefinedLabels(body, equs) → string[], // callees to stub (target syntax)
16
+ // emitStubs(names) → string, // `label: ret` stubs (target syntax)
17
+ // }
18
+ // A LIFTER is: lift(sourceAsm) → { ir, equs, instrCount, seamCount, entry }.
19
+ //
20
+ // Plain JS ESM + JSDoc.
21
+
22
+ import { collectResidue } from "./ir.js";
23
+ import { lift6502 } from "./lift-6502.js";
24
+ import { emit65816Body } from "./emit-65816.js";
25
+ import {
26
+ emitMainAsm as emit65816Wrapper, emitSeam as emit65816Seam,
27
+ findUndefinedLabels, emitStubs,
28
+ } from "../recompile-65816.js";
29
+ import {
30
+ emitm68kBody, emitM68kWrapper, emitM68kSeam, findUndefinedLabelsM68k, emitM68kStubs,
31
+ } from "./emit-m68k.js";
32
+
33
+ /** source ISA / platform → lifter. (gg/gbc/md aliases map to their base ISA.) */
34
+ const LIFTERS = {
35
+ nes: lift6502, "6502": lift6502,
36
+ // future: gb/sms → lift-sm83 / lift-z80; genesis → lift-m68k; etc.
37
+ };
38
+
39
+ /** target platform → emitter object. */
40
+ const EMITTERS = {
41
+ snes: {
42
+ targetPlatform: "snes", targetIsa: "65816",
43
+ emitBody: emit65816Body,
44
+ emitWrapper: (a) => emit65816Wrapper(a),
45
+ emitSeam: emit65816Seam,
46
+ seamFile: "nes_seam.asm",
47
+ findUndefinedLabels,
48
+ emitStubs,
49
+ },
50
+ genesis: {
51
+ targetPlatform: "genesis", targetIsa: "m68k",
52
+ emitBody: emitm68kBody,
53
+ emitWrapper: (a) => emitM68kWrapper(a),
54
+ emitSeam: emitM68kSeam,
55
+ seamFile: "nes_seam_md.asm",
56
+ findUndefinedLabels: findUndefinedLabelsM68k,
57
+ emitStubs: emitM68kStubs,
58
+ },
59
+ };
60
+
61
+ /** Resolve a source platform/ISA to its lifter, or throw with the supported set. */
62
+ function resolveLifter(source) {
63
+ const f = LIFTERS[source];
64
+ if (!f) throw new Error(`recompile: no lifter for source '${source}'. Supported sources: ${Object.keys(LIFTERS).filter((k) => k.length > 4 || /^[a-z]/.test(k)).join(", ")}.`);
65
+ return f;
66
+ }
67
+
68
+ /** Resolve a target platform to its emitter, or throw with the supported set. */
69
+ function resolveEmitter(target) {
70
+ const e = EMITTERS[target];
71
+ if (!e) throw new Error(`recompile: no emitter for target '${target}'. Supported targets: ${Object.keys(EMITTERS).join(", ")}.`);
72
+ return e;
73
+ }
74
+
75
+ /** The supported (source → target) pairs, for tool docs + capability reporting. */
76
+ export function supportedPairs() {
77
+ const sources = Object.keys(LIFTERS).filter((k) => !/^\d/.test(k)); // platform names, not bare ISA
78
+ const targets = Object.keys(EMITTERS);
79
+ const pairs = [];
80
+ for (const s of sources) for (const t of targets) if (s !== t) pairs.push(`${s}→${t}`);
81
+ return pairs;
82
+ }
83
+
84
+ /**
85
+ * Recompile a source-CPU disassembly to a target-CPU ROM image source, generically.
86
+ *
87
+ * @param {string} sourceAsm the da65/objdump disassembly of the source routine(s)
88
+ * @param {Object} opts
89
+ * @param {string} opts.source source platform/ISA (e.g. 'nes')
90
+ * @param {string} opts.target target platform (e.g. 'snes', 'genesis')
91
+ * @param {string} [opts.entry] override the entry label
92
+ * @param {boolean} [opts.stubUndefined=true] stub callees undefined in this slice
93
+ * @param {string} [opts.nmiSourceAsm] source disasm of the NMI handler (2nd body)
94
+ * @param {boolean} [opts.withShim] target-specific: include the static PPU shim
95
+ * @param {boolean} [opts.withRuntime] target-specific: include the per-frame runtime
96
+ * @returns {{ mainAsm, seamAsm, seamFile, residue, entry, nmiEntry, instrCount,
97
+ * seamCount, stubbed, source, target, targetIsa }}
98
+ */
99
+ export function recompile(sourceAsm, opts = {}) {
100
+ const source = opts.source || "nes";
101
+ const target = opts.target || "snes";
102
+ const lift = resolveLifter(source);
103
+ const emitter = resolveEmitter(target);
104
+
105
+ // 1. LIFT the reset/body to IR.
106
+ const lifted = lift(sourceAsm);
107
+ const body = emitter.emitBody(lifted.ir);
108
+ const residue = collectResidue(lifted.ir);
109
+
110
+ // 2. Optionally LIFT a second body (the NMI handler) for the live runtime.
111
+ let nmiBody = null;
112
+ let nmiEntry = null;
113
+ let nmiEqus = [];
114
+ let nmiResidue = [];
115
+ let nmiInstr = 0;
116
+ let nmiSeam = 0;
117
+ if (opts.withRuntime && opts.nmiSourceAsm) {
118
+ const nl = lift(opts.nmiSourceAsm);
119
+ nmiEntry = nl.entry;
120
+ nmiEqus = nl.equs;
121
+ nmiInstr = nl.instrCount;
122
+ nmiSeam = nl.seamCount;
123
+ nmiResidue = collectResidue(nl.ir);
124
+ // de-collide the synthetic fall-through entry between the two bodies
125
+ if (nmiEntry === "RECOMPILE_ENTRY") {
126
+ // rename in the IR before emit so the label is unique
127
+ for (const n of nl.ir) { if (n.op === "label" && n.name === "RECOMPILE_ENTRY") n.name = "RECOMPILE_NMI_ENTRY"; if (n.label === "RECOMPILE_ENTRY") n.label = "RECOMPILE_NMI_ENTRY"; }
128
+ nmiEntry = "RECOMPILE_NMI_ENTRY";
129
+ }
130
+ nmiBody = emitter.emitBody(nl.ir);
131
+ }
132
+
133
+ // 3. equs (address aliases) — union, de-duped, emitted once in the reset prefix.
134
+ const seen = new Set();
135
+ const allEqus = [...lifted.equs, ...nmiEqus].filter((e) => {
136
+ const name = e.split(/\s*=/)[0].trim();
137
+ if (seen.has(name)) return false;
138
+ seen.add(name);
139
+ return true;
140
+ });
141
+ const fullBody = (allEqus.length ? allEqus.join("\n") + "\n" : "") + body;
142
+ const entry = opts.entry || lifted.entry || "RECOMPILE_ENTRY";
143
+
144
+ // 4. Stub callees undefined across BOTH bodies (isolation), in target syntax.
145
+ const stubUndefined = opts.stubUndefined !== false;
146
+ const combined = fullBody + (nmiBody ? "\n" + nmiBody : "");
147
+ const stubbed = stubUndefined ? emitter.findUndefinedLabels(combined, allEqus) : [];
148
+ const withStubs = fullBody + (stubbed.length ? "\n" + emitter.emitStubs(stubbed) : "");
149
+
150
+ // 5. Wrap into the target ROM image.
151
+ const mainAsm = emitter.emitWrapper({
152
+ body: withStubs,
153
+ resetLabel: entry,
154
+ withShim: !!opts.withShim,
155
+ withRuntime: !!opts.withRuntime,
156
+ nmiBody,
157
+ });
158
+ const seamAsm = emitter.emitSeam();
159
+
160
+ return {
161
+ mainAsm, seamAsm, seamFile: emitter.seamFile,
162
+ residue: [...residue, ...nmiResidue],
163
+ entry, nmiEntry,
164
+ instrCount: lifted.instrCount + nmiInstr,
165
+ seamCount: lifted.seamCount + nmiSeam,
166
+ stubbed,
167
+ source, target, targetIsa: emitter.targetIsa,
168
+ };
169
+ }
@@ -0,0 +1,123 @@
1
+ // Generic recompile IR — the spine of the source/target-agnostic port engine.
2
+ //
3
+ // The port engine has two halves: a LIFTER turns one source-CPU instruction into
4
+ // IR node(s); an EMITTER turns IR node(s) into target-CPU assembly. Adding a
5
+ // platform PAIR is then one lifter + one emitter (registered by ISA name), not an
6
+ // N×N matrix of hand-written transpilers. NES→SNES is the first pair; the IR is
7
+ // what lets NES→Genesis (and any other pair whose ISAs have a lifter+emitter)
8
+ // reuse the SAME pipeline instead of a second bespoke recompiler.
9
+ //
10
+ // DESIGN PRINCIPLE: the IR is DELIBERATELY SMALL and HONEST. It models only the
11
+ // instruction classes a mechanical port actually needs, and REFUSES (rather than
12
+ // guesses) anything whose semantics don't carry across ISAs. A refused node is
13
+ // surfaced as residue, never silently mistranslated — the same contract the
14
+ // original 6502→65816 recompiler held. We are NOT building a full optimizing
15
+ // compiler IR (SSA, types, dataflow); we're building a normalized transfer format
16
+ // for "this instruction loads/stores/branches/calls/touches-hardware".
17
+ //
18
+ // IR node shape (a plain object; `op` is the discriminant):
19
+ // { op:'label', name } — a code label / branch target
20
+ // { op:'reg', mnemonic, operand, raw } — a register/ALU op that maps
21
+ // 1:1 across the source & target's compatible register file. `kind` tags the
22
+ // abstract operation so an emitter that ISN'T 1:1 can translate it.
23
+ // { op:'load', reg, addr, mode } — reg ← mem
24
+ // { op:'store', reg, addr, mode } — mem ← reg
25
+ // { op:'alu', fn, reg, operand, mode } — reg = reg fn operand
26
+ // { op:'branch', cond, target } — conditional relative branch
27
+ // { op:'jump', target } — unconditional jump
28
+ // { op:'call', target } — subroutine call (push return)
29
+ // { op:'ret', kind:'sub'|'interrupt' } — return
30
+ // { op:'hwreg', access:'read'|'write', reg, addr, via } — a hardware-MMIO access
31
+ // (the "seam"): the target can't do the source's MMIO, so this becomes a
32
+ // call into the target's runtime shim. `reg` is the source register-file
33
+ // address (e.g. NES $2000); `via` is the CPU register carrying the value.
34
+ // { op:'passthrough', text, raw } — emitter writes `text` as-is
35
+ // (used by a 1:1 emitter where the source mnemonic IS a valid target mnemonic)
36
+ // { op:'refuse', reason, raw } — not mechanically translatable;
37
+ // becomes residue. NEVER emitted as code.
38
+ //
39
+ // Every node may carry `label` (a leading code label) and `addr` (the source CPU
40
+ // address, for diagnostics). Plain JS ESM + JSDoc.
41
+
42
+ /** The IR op discriminants. */
43
+ export const IR = Object.freeze({
44
+ LABEL: "label",
45
+ REG: "reg",
46
+ LOAD: "load",
47
+ STORE: "store",
48
+ ALU: "alu",
49
+ BRANCH: "branch",
50
+ JUMP: "jump",
51
+ CALL: "call",
52
+ RET: "ret",
53
+ HWREG: "hwreg",
54
+ PASSTHROUGH: "passthrough",
55
+ REFUSE: "refuse",
56
+ });
57
+
58
+ /**
59
+ * Abstract operation kinds an emitter can switch on when it ISN'T a 1:1 textual
60
+ * passthrough (e.g. emitting m68k from 6502, where `lda`→`move.b` and the named
61
+ * 8-bit accumulator becomes a chosen data register). A lifter tags each `reg`/
62
+ * `alu`/`load`/`store`/`branch` node with one of these so the emitter never has
63
+ * to know the SOURCE mnemonic — only the abstract intent.
64
+ */
65
+ export const ABSTRACT = Object.freeze({
66
+ // data movement
67
+ LOAD_ACC: "load_acc", LOAD_X: "load_x", LOAD_Y: "load_y",
68
+ STORE_ACC: "store_acc", STORE_X: "store_x", STORE_Y: "store_y",
69
+ TRANSFER: "transfer",
70
+ PUSH: "push", PULL: "pull",
71
+ // arithmetic / logic (on the accumulator unless noted)
72
+ ADD: "add", SUB: "sub", AND: "and", OR: "or", XOR: "xor",
73
+ INC: "inc", DEC: "dec", SHL: "shl", SHR: "shr", ROL: "rol", ROR: "ror",
74
+ CMP: "cmp", BIT: "bit",
75
+ // index reg inc/dec/compare
76
+ INX: "inx", INY: "iny", DEX: "dex", DEY: "dey",
77
+ CPX: "cpx", CPY: "cpy",
78
+ // flags / nop
79
+ SET_FLAG: "set_flag", CLR_FLAG: "clr_flag", NOP: "nop",
80
+ });
81
+
82
+ /** Branch conditions (ISA-neutral). A lifter maps its native branches onto these;
83
+ * an emitter maps these onto the target's branch instructions. */
84
+ export const COND = Object.freeze({
85
+ EQ: "eq", NE: "ne", CC: "cc", CS: "cs", // zero/carry
86
+ MI: "mi", PL: "pl", VC: "vc", VS: "vs", // sign/overflow
87
+ ALWAYS: "always",
88
+ });
89
+
90
+ // ── node constructors (keep call sites terse + consistent) ──────────────────
91
+ export const irLabel = (name) => ({ op: IR.LABEL, name });
92
+ export const irPassthrough = (text, raw, label) => ({ op: IR.PASSTHROUGH, text, raw, label });
93
+ export const irRefuse = (reason, raw, label) => ({ op: IR.REFUSE, reason, raw, label });
94
+ export const irReg = (kind, mnemonic, operand, raw, label) => ({ op: IR.REG, kind, mnemonic, operand, raw, label });
95
+ export const irBranch = (cond, target, raw, label) => ({ op: IR.BRANCH, cond, target, raw, label });
96
+ export const irJump = (target, raw, label) => ({ op: IR.JUMP, target, raw, label });
97
+ export const irCall = (target, raw, label) => ({ op: IR.CALL, target, raw, label });
98
+ export const irRet = (kind, raw, label) => ({ op: IR.RET, kind, raw, label });
99
+ export const irHwReg = (access, reg, via, raw, label) => ({ op: IR.HWREG, access, reg, via, raw, label });
100
+
101
+ /**
102
+ * Validate an IR program (array of nodes) — a cheap structural check so a buggy
103
+ * lifter fails loudly here, not deep in an emitter. Returns the array unchanged
104
+ * or throws. Not a type system; just a guard that every node has a known `op`.
105
+ * @param {Array<object>} nodes
106
+ */
107
+ export function validateIR(nodes) {
108
+ if (!Array.isArray(nodes)) throw new Error("IR program must be an array of nodes");
109
+ const ops = new Set(Object.values(IR));
110
+ for (let i = 0; i < nodes.length; i++) {
111
+ const n = nodes[i];
112
+ if (!n || typeof n !== "object" || !ops.has(n.op)) {
113
+ throw new Error(`IR node ${i} has an unknown op: ${JSON.stringify(n)}`);
114
+ }
115
+ }
116
+ return nodes;
117
+ }
118
+
119
+ /** Collect the residue (refused nodes) from an IR program — what the engine
120
+ * could NOT mechanically translate, surfaced to the caller instead of guessed. */
121
+ export function collectResidue(nodes) {
122
+ return nodes.filter((n) => n.op === IR.REFUSE).map((n) => ({ reason: n.reason, line: (n.raw || "").trim() }));
123
+ }