romdevtools 0.42.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.43.0 — 2026-06-25
8
+
9
+ ### Port engine is now GENERIC (any source → any target), not NES→SNES-only
10
+
11
+ The recompiler was rebuilt around a small intermediate representation (IR): a
12
+ LIFTER turns a source-CPU disassembly into IR; an EMITTER turns IR into target-CPU
13
+ assembly. Adding a platform PAIR is now one lifter + one emitter, not a bespoke
14
+ recompiler. NES→SNES and the new NES→Genesis run through the *same* pipeline.
15
+
16
+ - **`disasm({target:'recompile', targetPlatform})`** — `'snes'` is the 1:1
17
+ emulation-mode port (with the PPU shim/runtime render layers); **`'genesis'` is a
18
+ real 6502→68000 LOGIC translation** — `lda #$42`→`move.b #$42,d0`,
19
+ `sta $0010`→`move.b d0,($FF0010).l`, branches/jsr/jmp mapped, the NES address
20
+ space mapped into Genesis work RAM at `$FF0000` (which IS the Tier-1 RAM-diff
21
+ oracle's mirror). Verified end-to-end: a real NES homebrew's reset routine
22
+ translates to m68k (0 residue), builds with vasm68k, and BOOTS in gpgx with the
23
+ translated logic executing (RAM writes land).
24
+ - The presentation seam (PPU/APU) is stubbed for non-SNES targets — it's a LOGIC
25
+ port; verify with `frame({op:'compareRam'})` vs the NES original. A render
26
+ runtime per target is a later layer (NES-PPU-on-SNES is the template).
27
+ - Unsupported targets fail with the supported set. `recompileNesToSnes` now
28
+ delegates to the generic engine (same image; existing SNES tests unchanged).
29
+ - New: `src/analysis/recompile/` (ir, lift-6502, emit-65816, emit-m68k, index).
30
+
7
31
  ## 0.42.0 — 2026-06-24
8
32
 
9
33
  ### v0.41.0 feedback batch + the NES→SNES port engine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -0,0 +1,105 @@
1
+ // IR → 65816 (SNES asar) emitter. The 65816 boots in 6502 EMULATION mode, so a
2
+ // 6502-sourced IR is "near-1:1": reg/branch/jump/call/return nodes re-emit the
3
+ // original mnemonic verbatim, and hwreg (the MMIO seam) becomes a call into the
4
+ // NES-PPU-on-SNES runtime. This emitter reproduces the byte-for-byte body the
5
+ // original monolithic recompile-65816 produced — it's the same output, reached
6
+ // through the generic IR so OTHER source ISAs (whose lifters emit the same IR)
7
+ // could target 65816 too, and so 6502 can target OTHER CPUs via a different emitter.
8
+ //
9
+ // Plain JS ESM + JSDoc.
10
+
11
+ import { IR } from "./ir.js";
12
+ import { NES_REGISTERS } from "../../platforms/common/registers.js";
13
+
14
+ /** The native 6502 mnemonic for each IR `reg` node is carried on the node itself
15
+ * (node.mnemonic); for 65816 emulation mode we re-emit it unchanged. */
16
+ function emitRegNode(node) {
17
+ const { mnemonic, operand } = node;
18
+ return ` ${mnemonic}${operand ? " " + operand : ""}`;
19
+ }
20
+
21
+ /** Emit the seam call(s) for one hwreg access — identical to the original
22
+ * emitSeamAccess: A holds the value (writes) / receives it (reads); the register
23
+ * low byte goes in X so one seam routine handles the whole file. */
24
+ function emitHwReg(node) {
25
+ const { access, reg, via } = node;
26
+ const regName = NES_REGISTERS[reg] || `REG_${reg.toString(16)}`;
27
+ const lowByte = `#$${(reg & 0xff).toString(16).padStart(2, "0")}`;
28
+ const lines = [` ; seam: ${via} $${reg.toString(16)} (${regName})`];
29
+ if (access === "write") {
30
+ if (via === "sta") {
31
+ lines.push(` ldx ${lowByte}`, ` jsr NES_PPU_WRITE`);
32
+ } else { // stx / sty
33
+ lines.push(` txa`, ` ldx ${lowByte}`, ` jsr NES_PPU_WRITE`);
34
+ }
35
+ } else { // read: lda / ldx / ldy / bit
36
+ lines.push(` ldx ${lowByte}`, ` jsr NES_PPU_READ`);
37
+ if (via === "bit") lines.push(` ; (bit set N/V from the read value)`);
38
+ }
39
+ return lines;
40
+ }
41
+
42
+ /**
43
+ * Emit the 65816 body from an IR program. Returns the body text (functions only;
44
+ * the LoROM wrapper/seam/vectors are added by the orchestrator via emitMainAsm).
45
+ * A leading label on a node is emitted as its own `label:` line, matching the
46
+ * original output exactly so the existing NES→SNES tests stay green.
47
+ * @param {Array<object>} ir
48
+ * @returns {string}
49
+ */
50
+ export function emit65816Body(ir) {
51
+ const out = [];
52
+ for (const node of ir) {
53
+ // node-level label (rides on instruction/branch/etc nodes)
54
+ if (node.label && node.op !== IR.LABEL) out.push(`${node.label}:`);
55
+ switch (node.op) {
56
+ case IR.LABEL:
57
+ out.push(`${node.name}:`);
58
+ break;
59
+ case IR.REG:
60
+ out.push(emitRegNode(node));
61
+ break;
62
+ case IR.BRANCH:
63
+ // 6502 branch mnemonics ARE valid 65816 — re-emit verbatim. node.raw holds
64
+ // the original "bne L8016"; recover the mnemonic from it for fidelity.
65
+ out.push(` ${branchMnemonic(node)} ${node.target}`);
66
+ break;
67
+ case IR.JUMP:
68
+ out.push(` jmp ${node.target}`);
69
+ break;
70
+ case IR.CALL:
71
+ out.push(` jsr ${node.target}`);
72
+ break;
73
+ case IR.RET:
74
+ out.push(node.kind === "interrupt" ? " rti" : " rts");
75
+ break;
76
+ case IR.HWREG:
77
+ out.push(...emitHwReg(node));
78
+ break;
79
+ case IR.PASSTHROUGH:
80
+ out.push(node.text);
81
+ break;
82
+ case IR.REFUSE:
83
+ // Visible marker so the asm still SHOWS where logic was dropped (residue
84
+ // is also reported structurally by the orchestrator).
85
+ out.push(` ; UNTRANSLATED: ${(node.raw || "").trim()} (${node.reason})`);
86
+ break;
87
+ default:
88
+ break;
89
+ }
90
+ }
91
+ return out.join("\n");
92
+ }
93
+
94
+ /** Recover the 6502 branch mnemonic for an IR branch node from its raw text (the
95
+ * cond→mnemonic map is 1:1 for 65816 emulation mode). */
96
+ function branchMnemonic(node) {
97
+ const m = (node.raw || "").trim().match(/^[a-zA-Z_]\w*:\s*([a-z]{3})|^([a-z]{3})/);
98
+ if (m) return (m[1] || m[2]);
99
+ // fall back from the ISA-neutral cond
100
+ return { eq: "beq", ne: "bne", cc: "bcc", cs: "bcs", mi: "bmi", pl: "bpl", vc: "bvc", vs: "bvs" }[node.cond] || "bne";
101
+ }
102
+
103
+ /** This emitter's target ISA + a human label (for the registry + diagnostics). */
104
+ export const TARGET_ISA = "65816";
105
+ export const TARGET_PLATFORM = "snes";
@@ -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
+ }
@@ -0,0 +1,176 @@
1
+ // 6502 → IR lifter. Turns one da65 6502 disassembly into the generic recompile
2
+ // IR (ir.js), tagging each instruction with an ABSTRACT op so a non-1:1 target
3
+ // emitter (m68k, z80, …) can translate the INTENT without knowing 6502. The 1:1
4
+ // emitter (65816 emulation mode) ignores the abstract tag and re-emits the
5
+ // mnemonic verbatim — both consume the same IR.
6
+ //
7
+ // This is the existing recompile-65816 classification logic, refactored to emit
8
+ // IR nodes instead of 65816 text. Same documented-opcode set, same seam
9
+ // detection, same refuse rules — but now source-agnostic at the boundary.
10
+ //
11
+ // Plain JS ESM + JSDoc.
12
+
13
+ import { parseDa65Line } from "../recompile-65816.js";
14
+ import { NES_REGISTERS } from "../../platforms/common/registers.js";
15
+ import {
16
+ ABSTRACT, COND,
17
+ irLabel, irReg, irBranch, irJump, irCall, irRet, irHwReg, irRefuse,
18
+ } from "./ir.js";
19
+
20
+ /** The 151 documented 6502 mnemonics — anything else (an undocumented opcode da65
21
+ * rendered, or a data byte caught in the stream) is REFUSED, not guessed. */
22
+ export const DOCUMENTED_6502 = new Set([
23
+ "adc", "and", "asl", "bcc", "bcs", "beq", "bit", "bmi", "bne", "bpl", "brk",
24
+ "bvc", "bvs", "clc", "cld", "cli", "clv", "cmp", "cpx", "cpy", "dec", "dex",
25
+ "dey", "eor", "inc", "inx", "iny", "jmp", "jsr", "lda", "ldx", "ldy", "lsr",
26
+ "nop", "ora", "pha", "php", "pla", "plp", "rol", "ror", "rti", "rts", "sbc",
27
+ "sec", "sed", "sei", "sta", "stx", "sty", "tax", "tay", "tsx", "txa", "txs",
28
+ "tya",
29
+ ]);
30
+
31
+ /** mnemonic → abstract op, so an emitter switches on INTENT not the 6502 name.
32
+ * (Branches/jumps/calls/returns/hwreg are handled structurally below, not here.) */
33
+ const ABSTRACT_OF = {
34
+ lda: ABSTRACT.LOAD_ACC, ldx: ABSTRACT.LOAD_X, ldy: ABSTRACT.LOAD_Y,
35
+ sta: ABSTRACT.STORE_ACC, stx: ABSTRACT.STORE_X, sty: ABSTRACT.STORE_Y,
36
+ tax: ABSTRACT.TRANSFER, tay: ABSTRACT.TRANSFER, txa: ABSTRACT.TRANSFER,
37
+ tya: ABSTRACT.TRANSFER, tsx: ABSTRACT.TRANSFER, txs: ABSTRACT.TRANSFER,
38
+ pha: ABSTRACT.PUSH, php: ABSTRACT.PUSH, pla: ABSTRACT.PULL, plp: ABSTRACT.PULL,
39
+ adc: ABSTRACT.ADD, sbc: ABSTRACT.SUB, and: ABSTRACT.AND, ora: ABSTRACT.OR,
40
+ eor: ABSTRACT.XOR, inc: ABSTRACT.INC, dec: ABSTRACT.DEC, asl: ABSTRACT.SHL,
41
+ lsr: ABSTRACT.SHR, rol: ABSTRACT.ROL, ror: ABSTRACT.ROR, cmp: ABSTRACT.CMP,
42
+ bit: ABSTRACT.BIT, inx: ABSTRACT.INX, iny: ABSTRACT.INY, dex: ABSTRACT.DEX,
43
+ dey: ABSTRACT.DEY, cpx: ABSTRACT.CPX, cpy: ABSTRACT.CPY,
44
+ clc: ABSTRACT.CLR_FLAG, sec: ABSTRACT.SET_FLAG, cli: ABSTRACT.CLR_FLAG,
45
+ sei: ABSTRACT.SET_FLAG, clv: ABSTRACT.CLR_FLAG, cld: ABSTRACT.CLR_FLAG,
46
+ nop: ABSTRACT.NOP,
47
+ };
48
+
49
+ /** 6502 conditional-branch mnemonic → ISA-neutral condition. */
50
+ const BRANCH_COND = {
51
+ beq: COND.EQ, bne: COND.NE, bcc: COND.CC, bcs: COND.CS,
52
+ bmi: COND.MI, bpl: COND.PL, bvc: COND.VC, bvs: COND.VS,
53
+ };
54
+
55
+ /**
56
+ * Detect a hardware-register (MMIO) access — the seam. Returns the register's low
57
+ * address if the operand targets a NES PPU/APU register, else null. Only absolute
58
+ * operands count; immediates and zero-page never hit the register file.
59
+ * @param {string|undefined} operand
60
+ */
61
+ export function seamRegister(operand) {
62
+ if (!operand) return null;
63
+ const m = operand.match(/^\$([0-9A-Fa-f]{3,4})(?:\s*,\s*[xy])?$/);
64
+ if (!m) return null;
65
+ const addr = parseInt(m[1], 16);
66
+ if (addr in NES_REGISTERS) return addr;
67
+ if ((addr >= 0x2000 && addr <= 0x2007) || (addr >= 0x4000 && addr <= 0x4017)) return addr;
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Lift one parsed da65 6502 line to an IR node (or null for blank/directive/equ —
73
+ * those are handled by the orchestrator, which collects equs separately). The
74
+ * leading label, if any, rides on the returned node so branch targets resolve.
75
+ * @param {ReturnType<typeof parseDa65Line>} p
76
+ * @returns {object|null}
77
+ */
78
+ function liftInstr(p) {
79
+ const label = p.label;
80
+ const mnem = p.mnemonic;
81
+ const operand = p.operand;
82
+
83
+ // Refuse the non-mechanical constructs up front (same as the original).
84
+ if (mnem === "sed") return irRefuse("decimal-mode (sed): BCD edge-flag semantics differ across ISAs", p.raw, label);
85
+ if (mnem === "jmp" && operand && operand.startsWith("(")) {
86
+ return irRefuse("indirect jump (jmp (addr)): target is computed — resolve with breakpoint({on:'jumptable'})", p.raw, label);
87
+ }
88
+
89
+ // Hardware seam: any PPU/APU register access becomes an IR hwreg node.
90
+ const reg = seamRegister(operand);
91
+ if (reg != null) {
92
+ const isStore = mnem === "sta" || mnem === "stx" || mnem === "sty";
93
+ const isLoad = mnem === "lda" || mnem === "ldx" || mnem === "ldy" || mnem === "bit";
94
+ if (isStore) return irHwReg("write", reg, mnem, p.raw, label);
95
+ if (isLoad) return irHwReg("read", reg, mnem, p.raw, label);
96
+ return irRefuse(`hardware-register read-modify-write (${mnem} ${operand}): not in the mechanical set`, p.raw, label);
97
+ }
98
+
99
+ // Control flow → structural IR nodes.
100
+ if (mnem === "jsr") return irCall(operand, p.raw, label);
101
+ if (mnem === "jmp") return irJump(operand, p.raw, label);
102
+ if (mnem === "rts") return irRet("sub", p.raw, label);
103
+ if (mnem === "rti") return irRet("interrupt", p.raw, label);
104
+ if (mnem === "brk") return irRefuse("brk: software interrupt needs explicit vector handling in v1", p.raw, label);
105
+ if (mnem in BRANCH_COND) return irBranch(BRANCH_COND[mnem], operand, p.raw, label);
106
+
107
+ // A documented data/ALU/transfer op → a `reg` node tagged with its abstract op.
108
+ if (mnem in ABSTRACT_OF) {
109
+ return irReg(ABSTRACT_OF[mnem], mnem, operand, p.raw, label);
110
+ }
111
+
112
+ // Unknown mnemonic = undocumented opcode or a parse miss.
113
+ return irRefuse(`unrecognized/undocumented opcode '${mnem}' — not a documented 6502 instruction`, p.raw, label);
114
+ }
115
+
116
+ /**
117
+ * Lift a block of da65 6502 asm text into an IR program. Returns the IR node list,
118
+ * the collected equ definitions (address aliases), and a flag for whether the
119
+ * first instruction was anchored (so the orchestrator can fix the entry label).
120
+ * @param {string} da65Asm
121
+ * @returns {{ ir: Array<object>, equs: string[], instrCount: number, seamCount: number, entry: string|null }}
122
+ */
123
+ export function lift6502(da65Asm) {
124
+ const lines = da65Asm.split("\n");
125
+ const ir = [];
126
+ const equs = [];
127
+ let instrCount = 0;
128
+ let seamCount = 0;
129
+ let entry = null;
130
+ const ENTRY_LABEL = "RECOMPILE_ENTRY";
131
+
132
+ for (const raw of lines) {
133
+ const p = parseDa65Line(raw);
134
+ switch (p.kind) {
135
+ case "blank":
136
+ case "comment":
137
+ case "directive":
138
+ break;
139
+ case "equ":
140
+ equs.push(`${p.label} = ${p.operand}`);
141
+ break;
142
+ case "label":
143
+ ir.push(irLabel(p.label));
144
+ break;
145
+ case "data":
146
+ ir.push(irRefuse("data/.byte in code stream (data table or undocumented opcode)", p.raw));
147
+ break;
148
+ case "instr": {
149
+ const node = liftInstr(p);
150
+ if (!node) break;
151
+ // Anchor the entry to the FIRST real instruction (lifted node), labeled or
152
+ // not — da65 only labels branch targets, so a fall-through opener is
153
+ // unlabeled and would otherwise let the reset skip the routine's setup.
154
+ if (entry == null && node.op !== "refuse") {
155
+ if (node.label) {
156
+ entry = node.label;
157
+ } else {
158
+ entry = ENTRY_LABEL;
159
+ ir.push(irLabel(ENTRY_LABEL));
160
+ }
161
+ } else if (entry == null && node.op === "refuse") {
162
+ // even a refused opener anchors the entry so the vector lands at the top
163
+ if (node.label) entry = node.label;
164
+ else { entry = ENTRY_LABEL; ir.push(irLabel(ENTRY_LABEL)); }
165
+ }
166
+ if (node.op === "hwreg") seamCount++;
167
+ if (node.op !== "refuse") instrCount++;
168
+ ir.push(node);
169
+ break;
170
+ }
171
+ default:
172
+ break;
173
+ }
174
+ }
175
+ return { ir, equs, instrCount, seamCount, entry };
176
+ }
@@ -22,6 +22,7 @@
22
22
  // Plain JS ESM + JSDoc. See internal-romdev/PORTING_MENTAL_MODELS.md Part 4.
23
23
 
24
24
  import { NES_REGISTERS } from "../platforms/common/registers.js";
25
+ import { recompile } from "./recompile/index.js";
25
26
 
26
27
  /**
27
28
  * The 151 documented 6502 mnemonics. Anything da65 emits outside this set — or
@@ -375,73 +376,21 @@ export function sliceFirstRoutine(da65Asm) {
375
376
  * seamCount: number, stubbed: string[] }}
376
377
  */
377
378
  export function recompileNesToSnes(da65Asm, opts = {}) {
378
- const { body, equs, residue, instrCount, seamCount, entry: bodyEntry } = translateBody(da65Asm);
379
-
380
- // Optionally translate the NES NMI handler as a second body (phase-2 runtime).
381
- let nmiBody = null;
382
- let nmiEntry = null;
383
- let nmiEqus = [];
384
- let nmiResidue = [];
385
- let nmiInstr = 0;
386
- let nmiSeam = 0;
387
- if (opts.withRuntime && opts.nmiDa65Asm) {
388
- const t = translateBody(opts.nmiDa65Asm);
389
- nmiBody = t.body;
390
- nmiEntry = t.entry;
391
- nmiEqus = t.equs;
392
- nmiResidue = t.residue;
393
- nmiInstr = t.instrCount;
394
- nmiSeam = t.seamCount;
395
- // The two bodies may both define an entry named RECOMPILE_ENTRY (the synthetic
396
- // fall-through label). Rename the NMI's so they don't collide in one image.
397
- if (nmiEntry === "RECOMPILE_ENTRY") {
398
- nmiBody = nmiBody.replace(/\bRECOMPILE_ENTRY\b/g, "RECOMPILE_NMI_ENTRY");
399
- nmiEntry = "RECOMPILE_NMI_ENTRY";
400
- }
401
- }
402
-
403
- // Equs (zero-page/external address aliases like `L001E = $001E`) are shared
404
- // between the two bodies — emit the UNION once, de-duplicated, in the reset
405
- // body's prefix; the NMI body carries none (a duplicate `=` is an asar error).
406
- const seenEqu = new Set();
407
- const allEqus = [...equs, ...nmiEqus].filter((e) => {
408
- const name = e.split(/\s*=/)[0].trim();
409
- if (seenEqu.has(name)) return false;
410
- seenEqu.add(name);
411
- return true;
412
- });
413
- const fullBody = (allEqus.length ? allEqus.join("\n") + "\n" : "") + body;
414
- const fullNmiBody = nmiBody; // equs already hoisted above
415
- // entry: caller override > the first-instruction label translateBody fixed >
416
- // first label in the body (legacy fallback) > a synthetic name. bodyEntry is
417
- // the correct one — it anchors the reset vector to the routine's OPENING
418
- // instruction, not a later branch target.
419
- const entry = opts.entry || bodyEntry || entryLabel(fullBody) || "RECOMPILE_ENTRY";
420
-
421
- // Stub callees undefined across BOTH bodies (so a routine the reset calls that
422
- // happens to be defined inside the NMI body — or vice versa — is not stubbed).
423
- const stubUndefined = opts.stubUndefined !== false;
424
- const combined = fullBody + (fullNmiBody ? "\n" + fullNmiBody : "");
425
- const stubbed = stubUndefined ? findUndefinedLabels(combined, allEqus) : [];
426
- const stubsAsm = stubbed.length ? "\n" + emitStubs(stubbed) : "";
427
- const withStubs = fullBody + stubsAsm;
428
-
429
- const mainAsm = emitMainAsm({
430
- body: withStubs,
431
- resetLabel: entry,
432
- withShim: !!opts.withShim,
433
- withRuntime: !!opts.withRuntime,
434
- nmiBody: fullNmiBody,
379
+ // Delegate to the GENERIC engine (lift 6502 IR emit 65816). This is now a
380
+ // thin source/target-pinned wrapper kept for back-compat with the disasm tool +
381
+ // existing tests; the actual translation lives in analysis/recompile/. The
382
+ // generic path produces the same image (the 65816 emitter reproduces this
383
+ // module's emitMainAsm/emitSeam output) — see analysis/recompile/index.js.
384
+ // `nmiDa65Asm` is the old arg name; the generic engine calls it `nmiSourceAsm`.
385
+ return recompile(da65Asm, {
386
+ source: "nes",
387
+ target: "snes",
388
+ entry: opts.entry,
389
+ stubUndefined: opts.stubUndefined,
390
+ withShim: opts.withShim,
391
+ withRuntime: opts.withRuntime,
392
+ nmiSourceAsm: opts.nmiDa65Asm,
435
393
  });
436
- const seamAsm = emitSeam();
437
- return {
438
- mainAsm, seamAsm,
439
- residue: [...residue, ...nmiResidue],
440
- entry, nmiEntry,
441
- instrCount: instrCount + nmiInstr,
442
- seamCount: seamCount + nmiSeam,
443
- stubbed,
444
- };
445
394
  }
446
395
 
447
396
  /**
@@ -1227,12 +1227,19 @@ async function pointerTableCore(args) {
1227
1227
  }
1228
1228
 
1229
1229
  async function recompileCore(args) {
1230
- const { platform, targetPlatform } = args;
1230
+ const { platform } = args;
1231
+ const targetPlatform = args.targetPlatform || "snes";
1231
1232
  if (platform && platform !== "nes") {
1232
- throw new Error(`disasm({target:'recompile'}): only NES source is supported in phase 1 (got '${platform}').`);
1233
+ throw new Error(`disasm({target:'recompile'}): only NES source is supported today (got '${platform}'). The engine is generic (lift→IR→emit); other source lifters land as they're built.`);
1233
1234
  }
1234
- if (targetPlatform && targetPlatform !== "snes") {
1235
- throw new Error(`disasm({target:'recompile'}): only SNES target is supported in phase 1 (got '${targetPlatform}').`);
1235
+ // The generic engine targets any platform with a registered emitter. SNES is the
1236
+ // 1:1 emulation-mode path (with the PPU shim/runtime render layers); Genesis is a
1237
+ // real 6502→68000 LOGIC translation (presentation seam stubbed — verify with the
1238
+ // RAM-diff oracle, frame({op:'compareRam'})).
1239
+ const { supportedPairs } = await import("../../analysis/recompile/index.js");
1240
+ const validTargets = new Set(supportedPairs().filter((p) => p.startsWith("nes→")).map((p) => p.split("→")[1]));
1241
+ if (!validTargets.has(targetPlatform)) {
1242
+ throw new Error(`disasm({target:'recompile'}): no emitter for target '${targetPlatform}'. Supported NES→ targets: ${[...validTargets].join(", ")}.`);
1236
1243
  }
1237
1244
  const romPath = requireRomPath(args);
1238
1245
  const rom = new Uint8Array(await readFile(romPath));
@@ -1289,8 +1296,12 @@ async function recompileCore(args) {
1289
1296
  // The phase-2 runtime (withRuntime) implies the shim (it needs the BG + tiles
1290
1297
  // the shim uploads); enabling it turns the static port into a LIVE one (sprites
1291
1298
  // animate each vblank, the game's NMI runs). withShim alone is the static path.
1292
- const wantRuntime = args.withRuntime === true;
1293
- const wantShim = args.withShim === true || wantRuntime;
1299
+ // The PPU shim/runtime are NES-PPU-on-SNES render layers — SNES target only. A
1300
+ // Genesis (or other) target is a LOGIC port (presentation seam stubbed); verify
1301
+ // it with the RAM-diff oracle, not a rendered screen.
1302
+ const snesTarget = targetPlatform === "snes";
1303
+ const wantRuntime = snesTarget && args.withRuntime === true;
1304
+ const wantShim = snesTarget && (args.withShim === true || wantRuntime);
1294
1305
  let shimAsm = null;
1295
1306
  let shimInfo = null;
1296
1307
  if (wantShim) {
@@ -1328,11 +1339,14 @@ async function recompileCore(args) {
1328
1339
  }
1329
1340
  }
1330
1341
 
1331
- const { mainAsm, seamAsm, residue, entry, nmiEntry, instrCount, seamCount, stubbed } =
1332
- recompileNesToSnes(da65Asm, {
1342
+ const { recompile } = await import("../../analysis/recompile/index.js");
1343
+ const { mainAsm, seamAsm, seamFile, residue, entry, nmiEntry, instrCount, seamCount, stubbed, targetIsa } =
1344
+ recompile(da65Asm, {
1345
+ source: "nes",
1346
+ target: targetPlatform,
1333
1347
  withShim: !!shimAsm,
1334
1348
  withRuntime: !!runtimeAsm,
1335
- nmiDa65Asm,
1349
+ nmiSourceAsm: nmiDa65Asm,
1336
1350
  });
1337
1351
 
1338
1352
  const runtimeOn = !!runtimeAsm;
@@ -1348,7 +1362,7 @@ async function recompileCore(args) {
1348
1362
  await writeFile(rtPath, runtimeAsm);
1349
1363
  written.runtimeAsm = rtPath;
1350
1364
  } else {
1351
- const seamPath = nodePath.join(args.outputDir, "nes_seam.asm");
1365
+ const seamPath = nodePath.join(args.outputDir, seamFile);
1352
1366
  await writeFile(seamPath, seamAsm);
1353
1367
  written.seamAsm = seamPath;
1354
1368
  }
@@ -1362,30 +1376,36 @@ async function recompileCore(args) {
1362
1376
  const shimDrawn = !!shimAsm;
1363
1377
  return jsonContent({
1364
1378
  ok: true,
1365
- source: "nes", target: "snes",
1379
+ source: "nes", target: targetPlatform, targetIsa,
1366
1380
  resetVector: "$" + resetVec.toString(16).toUpperCase().padStart(4, "0"),
1367
1381
  entry,
1368
1382
  ...(runtimeOn ? { nmiEntry, nmi: nmiInfo } : {}),
1369
1383
  instrCount, seamCount,
1370
1384
  stubbedCallees: stubbed,
1371
1385
  residue,
1372
- shim: shimDrawn
1373
- ? { applied: true, phase: runtimeOn ? "live-background" : "static-boot-picture", tiles: shimInfo.tileCount, note: "Emitted the NES-PPU-on-SNES shim (converted tiles/nametable/palette → VRAM/CGRAM, BG1 on). It draws the original ROM's boot screen background." }
1374
- : { applied: false, reason: shimInfo?.error || "withShim not set (default off)", note: "No PPU shim the port runs the logic but renders blank. Pass withShim:true (static) or withRuntime:true (live) to draw the original ROM's picture on SNES." },
1375
- runtime: runtimeOn
1376
- ? { applied: true, phase: "live-sprites", gameNmi: nmiInfo?.gameNmi || null, note: "PHASE 2: the per-frame runtime is wired — each vblank it flushes the game's shadow OAM to SNES sprites and runs the game's NMI handler, so sprites ANIMATE (not a static screenshot). Background is from the shim; live nametable streaming is phase 3. Build all emitted files together with build({platform:'snes'})." }
1377
- : { applied: false, note: "Static port (no per-frame runtime). Pass withRuntime:true to animate sprites + run the game's NMI each vblank." },
1386
+ ...(snesTarget ? {
1387
+ shim: shimDrawn
1388
+ ? { applied: true, phase: runtimeOn ? "live-background" : "static-boot-picture", tiles: shimInfo.tileCount, note: "Emitted the NES-PPU-on-SNES shim (converted tiles/nametable/palette VRAM/CGRAM, BG1 on). It draws the original ROM's boot screen background." }
1389
+ : { applied: false, reason: shimInfo?.error || "withShim not set (default off)", note: "No PPU shim — the port runs the logic but renders blank. Pass withShim:true (static) or withRuntime:true (live) to draw the original ROM's picture on SNES." },
1390
+ runtime: runtimeOn
1391
+ ? { applied: true, phase: "live-sprites", gameNmi: nmiInfo?.gameNmi || null, note: "PHASE 2: the per-frame runtime is wired — each vblank it flushes the game's shadow OAM to SNES sprites and runs the game's NMI handler, so sprites ANIMATE (not a static screenshot). Background is from the shim; live nametable streaming is phase 3. Build all emitted files together with build({platform:'snes'})." }
1392
+ : { applied: false, note: "Static port (no per-frame runtime). Pass withRuntime:true to animate sprites + run the game's NMI each vblank." },
1393
+ } : {
1394
+ port: { kind: "logic-recompile", targetIsa, note: `LOGIC port: the NES 6502 was TRANSLATED to ${targetIsa} (not emulated) — game logic runs, the PPU/APU presentation seam is STUBBED. Build with the platform's toolchain, then VERIFY with the RAM-diff oracle: load the original NES ROM and this port side by side and frame({op:'compareRam'}) the work-RAM mirror. Presentation (a ${targetPlatform} render runtime) is a separate layer.` },
1395
+ }),
1378
1396
  note:
1379
- `Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to 65816 (asar). ${instrCount} instrs, ${seamCount} PPU/APU seam calls, ` +
1397
+ `Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to ${targetIsa}. ${instrCount} instrs, ${seamCount} PPU/APU seam calls, ` +
1380
1398
  `${stubbed.length} callee(s) stubbed (isolation), ${residue.length} residue line(s). ` +
1381
- (runtimeOn
1382
- ? "PHASE 2: the 6502 logic runs in emulation mode; the runtime flushes sprites + runs the game NMI every vblank, and the shim draws the BG — so the port is LIVE (sprites move). "
1383
- : shimDrawn
1384
- ? `The 6502 logic runs in 65816 EMULATION mode; the shim draws the converted static boot picture (${shimInfo.tileCount} tiles). `
1385
- : "The 6502 logic runs in 65816 EMULATION mode; the hardware seam is STUBBED so the port renders blank. ") +
1399
+ (snesTarget
1400
+ ? (runtimeOn
1401
+ ? "PHASE 2: the 6502 logic runs in emulation mode; the runtime flushes sprites + runs the game NMI every vblank, and the shim draws the BG — so the port is LIVE (sprites move). "
1402
+ : shimDrawn
1403
+ ? `The 6502 logic runs in 65816 EMULATION mode; the shim draws the converted static boot picture (${shimInfo.tileCount} tiles). `
1404
+ : "The 6502 logic runs in 65816 EMULATION mode; the hardware seam is STUBBED so the port renders blank. ")
1405
+ : `The 6502 logic is TRANSLATED to ${targetIsa} (real ISA translation, not emulation); the PPU/APU seam is STUBBED so it's a LOGIC port — verify with frame({op:'compareRam'}) vs the NES original. `) +
1386
1406
  (written
1387
- ? `Wrote ${Object.values(written).join(" + ")}. Build all of them together with build({platform:'snes'}); then loadMedia + frame({op:'sideBySide'}) vs the NES original.`
1388
- : `Pass outputDir to write the .asm files to disk for build({platform:'snes'}).`),
1407
+ ? `Wrote ${Object.values(written).join(" + ")}. Build with the ${targetPlatform} toolchain; then loadMedia + frame({op:'compareRam'}) vs the NES original.`
1408
+ : `Pass outputDir to write the .asm files to disk for build({platform:'${targetPlatform}'}).`),
1389
1409
  ...(written ? { written } : { mainAsm, ...(runtimeOn ? { runtimeAsm } : { seamAsm }), ...(shimAsm ? { shimAsm } : {}) }),
1390
1410
  });
1391
1411
  }
@@ -1495,7 +1515,7 @@ export function registerDisasmTools(server, z) {
1495
1515
  annotateFileOffsets: z.boolean().default(true).describe("target=rom: append `; @0xNNNN` file offset to every line (for romPatch)."),
1496
1516
  // project
1497
1517
  outputDir: z.string().optional().describe("target=project: directory to write the project into (one .asm per region). target=recompile: directory to write main.asm + nes_seam.asm for build({platform:'snes'})."),
1498
- targetPlatform: z.string().optional().describe("target=recompile: the platform to emit (phase 1: only 'snes'). The source platform is `platform` (phase 1: only 'nes')."),
1518
+ targetPlatform: z.string().optional().describe("target=recompile: the platform to EMIT (default 'snes'). The engine is generic (lift source→IR→emit target): 'snes' = 1:1 emulation-mode port with the PPU render layers (withShim/withRuntime); 'genesis' = real 6502→68000 LOGIC translation (presentation stubbed, verify with frame({op:'compareRam'})). Source `platform` is 'nes' today; more source lifters land as built."),
1499
1519
  withShim: z.boolean().default(false).describe("target=recompile: phase-1 STATIC render (default off). Emit the NES-PPU-on-SNES shim — boots the original ROM, converts its tiles/nametable/palette to SNES VRAM/CGRAM data + a 65816 upload routine that draws the original's STATIC boot screen on SNES (verified on snes9x). Draws the first screen only; sprites don't animate. For a LIVE port use withRuntime instead."),
1500
1520
  withRuntime: z.boolean().default(false).describe("target=recompile: phase-2 LIVE render (default off). Implies withShim (BG) and adds the per-frame runtime: each vblank it flushes the game's shadow OAM to SNES sprites and runs the game's own NMI handler, so SPRITES ANIMATE and the game's per-frame logic runs — the port plays, not just boots to a screenshot. Background is static from the shim; live nametable/scroll streaming is phase 3. Verified on snes9x."),
1501
1521
  // references / cfg / xrefs