romdevtools 0.42.0 → 0.44.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,59 @@ 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.44.0 — 2026-06-25
8
+
9
+ ### v0.41.0 feedback (part 2) — RE-session ergonomics
10
+
11
+ - **`watch({on:'range'})` dedupe + digest.** A range watch over a churny window
12
+ flooded with per-frame writes (a counter inc'd at one PC → hundreds of
13
+ near-identical rows). New `dedupe:true` collapses identical `(pc,address,value)`
14
+ events to one row with an `occurrences` count (parity with `on:'dma'`), and
15
+ `distinctPCsOnly:true` returns JUST the per-PC digest (`byPC[{pc,count,
16
+ sampleAddress,sampleValue}]`) and suppresses the raw event list — the
17
+ token-cheap "which routines touch this range?" answer. (133737 N1)
18
+ - **`catalog({op:'status'}).capabilities` build-toolchain flags.** Added
19
+ `cc65Build`, `ld65Link`, `da65Disasm` so an agent knows before calling
20
+ `build`/`disasm` whether the toolchain is present (the analysis subtargets
21
+ cfg/xrefs/functions/decompile are all da65-backed). (184553 #1, 190223 #1)
22
+ - **`cheats` slot lifecycle.** `apply` now REPLACES an active freeze on the same
23
+ address instead of stacking a second one that fights for the byte
24
+ (`replacedSameAddress`), and new `op:'remove'` drops ONE cheat by slot/code
25
+ without `clear`'s nuke-all. (213831 #3)
26
+ - **`breakpoint` `settleFrames`.** Back-to-back driven runs on the same live host
27
+ could inherit the prior run's held-button shadow on frame 0 (false-positiving a
28
+ negative control). `settleFrames:N` releases the pad to neutral and steps N
29
+ frames before the run so it starts clean. (213831 #1)
30
+ - **`breakpoint({on:'pc'})` miss-note** now names the negative-control case: a
31
+ `hit:false` may be the DESIRED result (proving input X does NOT reach a branch),
32
+ not a wrong-address failure. (213831 #2)
33
+ - Confirmed already shipped (0.42.0): hex-string `address`/`offset` params, the
34
+ base capabilities map. (133737 N2, 002129 #2)
35
+
36
+ ## 0.43.0 — 2026-06-25
37
+
38
+ ### Port engine is now GENERIC (any source → any target), not NES→SNES-only
39
+
40
+ The recompiler was rebuilt around a small intermediate representation (IR): a
41
+ LIFTER turns a source-CPU disassembly into IR; an EMITTER turns IR into target-CPU
42
+ assembly. Adding a platform PAIR is now one lifter + one emitter, not a bespoke
43
+ recompiler. NES→SNES and the new NES→Genesis run through the *same* pipeline.
44
+
45
+ - **`disasm({target:'recompile', targetPlatform})`** — `'snes'` is the 1:1
46
+ emulation-mode port (with the PPU shim/runtime render layers); **`'genesis'` is a
47
+ real 6502→68000 LOGIC translation** — `lda #$42`→`move.b #$42,d0`,
48
+ `sta $0010`→`move.b d0,($FF0010).l`, branches/jsr/jmp mapped, the NES address
49
+ space mapped into Genesis work RAM at `$FF0000` (which IS the Tier-1 RAM-diff
50
+ oracle's mirror). Verified end-to-end: a real NES homebrew's reset routine
51
+ translates to m68k (0 residue), builds with vasm68k, and BOOTS in gpgx with the
52
+ translated logic executing (RAM writes land).
53
+ - The presentation seam (PPU/APU) is stubbed for non-SNES targets — it's a LOGIC
54
+ port; verify with `frame({op:'compareRam'})` vs the NES original. A render
55
+ runtime per target is a later layer (NES-PPU-on-SNES is the template).
56
+ - Unsupported targets fail with the supported set. `recompileNesToSnes` now
57
+ delegates to the generic engine (same image; existing SNES tests unchanged).
58
+ - New: `src/analysis/recompile/` (ir, lift-6502, emit-65816, emit-m68k, index).
59
+
7
60
  ## 0.42.0 — 2026-06-24
8
61
 
9
62
  ### 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.44.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
+ }