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.
- package/CHANGELOG.md +94 -0
- package/package.json +3 -1
- package/src/analysis/analyze.js +1 -1
- package/src/analysis/backtrace.js +198 -0
- package/src/analysis/nes-ppu-runtime.js +220 -0
- package/src/analysis/nes-ppu-shim.js +263 -0
- package/src/analysis/pointer-table.js +84 -0
- package/src/analysis/recompile/emit-65816.js +105 -0
- package/src/analysis/recompile/emit-m68k.js +295 -0
- package/src/analysis/recompile/index.js +169 -0
- package/src/analysis/recompile/ir.js +123 -0
- package/src/analysis/recompile/lift-6502.js +176 -0
- package/src/analysis/recompile-65816.js +533 -0
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/registry.js +43 -51
- package/src/mcp/state.js +91 -2
- package/src/mcp/tools/cheats.js +10 -1
- package/src/mcp/tools/disasm.js +329 -21
- package/src/mcp/tools/find-references.js +93 -2
- package/src/mcp/tools/frame.js +440 -4
- package/src/mcp/tools/index.js +34 -1
- package/src/mcp/tools/lifecycle.js +53 -24
- package/src/mcp/tools/memory.js +1 -1
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +4 -2
- package/src/mcp/tools/watch-memory.js +87 -9
- package/src/mcp/util.js +45 -0
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/cc65/da65.js +7 -0
- package/src/cores/wasm/bluemsx_libretro.js +0 -2
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +0 -2
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +0 -2
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +0 -2
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +0 -2
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +0 -2
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +0 -2
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +0 -2
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +0 -2
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +0 -2
- 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
|
+
}
|