romdevtools 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile-65816.js +584 -0
  9. package/src/cheats/gamegenie.js +14 -2
  10. package/src/cores/registry.js +43 -51
  11. package/src/mcp/state.js +91 -2
  12. package/src/mcp/tools/cheats.js +10 -1
  13. package/src/mcp/tools/disasm.js +309 -21
  14. package/src/mcp/tools/find-references.js +93 -2
  15. package/src/mcp/tools/frame.js +440 -4
  16. package/src/mcp/tools/index.js +34 -1
  17. package/src/mcp/tools/lifecycle.js +53 -24
  18. package/src/mcp/tools/memory.js +1 -1
  19. package/src/mcp/tools/playtest.js +24 -1
  20. package/src/mcp/tools/project.js +1 -1
  21. package/src/mcp/tools/rendering-context.js +4 -2
  22. package/src/mcp/tools/watch-memory.js +87 -9
  23. package/src/mcp/util.js +45 -0
  24. package/src/playtest/playtest.js +115 -46
  25. package/src/toolchains/cc65/da65.js +7 -0
  26. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  27. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  28. package/src/cores/wasm/fceumm_libretro.js +0 -2
  29. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  30. package/src/cores/wasm/gambatte_libretro.js +0 -2
  31. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  32. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  33. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  34. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  35. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  36. package/src/cores/wasm/handy_libretro.js +0 -2
  37. package/src/cores/wasm/handy_libretro.wasm +0 -0
  38. package/src/cores/wasm/mgba_libretro.js +0 -2
  39. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  40. package/src/cores/wasm/prosystem_libretro.js +0 -2
  41. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  42. package/src/cores/wasm/snes9x_libretro.js +0 -2
  43. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  44. package/src/cores/wasm/stella2014_libretro.js +0 -2
  45. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  46. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  47. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -0,0 +1,584 @@
1
+ // NES (6502) → SNES (65816) static recompile — emit backend, phase 1.
2
+ //
3
+ // The flagship port-engine's emit half. romdev already LIFTS a ROM (functions,
4
+ // CFG, decompile via the rizin engine); this is the inverse — it EMITS a target
5
+ // CPU. NES→SNES is the pilot because the 65816 boots in 6502 EMULATION mode, so
6
+ // the game's 6502 logic runs essentially unmodified: most instructions are a 1:1
7
+ // textual rewrite of the da65 disassembly into asar 65816 syntax. The only real
8
+ // work is the hardware seam (NES PPU/APU registers have no meaning on SNES) and
9
+ // refusing the handful of constructs that DON'T map mechanically.
10
+ //
11
+ // This module is the translator. Its input is the da65 6502 asm that
12
+ // disasm({target:'rom'}) already produces; its output is asar-ready 65816 source
13
+ // (a main.asm + a seam include) plus a residue report of anything it refused to
14
+ // translate. It builds via the bundled asar toolchain and is verified by booting
15
+ // the result and comparing against the NES original with frame({op:'sideBySide'}).
16
+ //
17
+ // SCOPE (phase 1 / M1-M2): NROM (mapper 0), documented 6502 opcodes, the boot +
18
+ // vblank-driven structure. OUT of scope (each its own later task): a real
19
+ // NES-PPU-on-SNES runtime shim (the seam is STUBBED here, see emitSeam), APU
20
+ // audio, mappers > 0, undocumented opcodes (refused), cycle-timed raster splits.
21
+ //
22
+ // Plain JS ESM + JSDoc. See internal-romdev/PORTING_MENTAL_MODELS.md Part 4.
23
+
24
+ import { NES_REGISTERS } from "../platforms/common/registers.js";
25
+
26
+ /**
27
+ * The 151 documented 6502 mnemonics. Anything da65 emits outside this set — or
28
+ * any `.byte` fallback inside a code path — is an undocumented opcode, which is
29
+ * a DIFFERENT instruction on the 65816 and must be refused, not mistranslated.
30
+ */
31
+ export const DOCUMENTED_6502 = new Set([
32
+ "adc", "and", "asl", "bcc", "bcs", "beq", "bit", "bmi", "bne", "bpl", "brk",
33
+ "bvc", "bvs", "clc", "cld", "cli", "clv", "cmp", "cpx", "cpy", "dec", "dex",
34
+ "dey", "eor", "inc", "inx", "iny", "jmp", "jsr", "lda", "ldx", "ldy", "lsr",
35
+ "nop", "ora", "pha", "php", "pla", "plp", "rol", "ror", "rti", "rts", "sbc",
36
+ "sec", "sed", "sei", "sta", "stx", "sty", "tax", "tay", "tsx", "txa", "txs",
37
+ "tya",
38
+ ]);
39
+
40
+ /**
41
+ * Instructions that, in 65816 EMULATION mode (E=1), behave IDENTICALLY to the
42
+ * 6502 — same encoding intent, same flags, 8-bit A/X/Y, stack pinned to page 1,
43
+ * direct page 0. These pass through as the exact same mnemonic + operand. This
44
+ * is the whole "near-free" property: no rep/sep width management is needed for
45
+ * game logic because E-mode forces the 6502 register widths.
46
+ *
47
+ * The set is DOCUMENTED_6502 minus the ones needing special handling:
48
+ * - brk / rti → interrupt structure differs; handled at the seam/vectors
49
+ * - sed → decimal mode flag semantics differ on 65816 (refused if
50
+ * decimal arithmetic follows; bare cld/sed flag ops are ok
51
+ * but we flag sed as residue to be safe)
52
+ * Everything else, including all addressing modes, is a pass-through.
53
+ */
54
+ const PASSTHROUGH = new Set(
55
+ [...DOCUMENTED_6502].filter((m) => !["brk", "sed", "rti"].includes(m)),
56
+ );
57
+
58
+ /** A parsed da65 line. */
59
+ /**
60
+ * @typedef {Object} ParsedLine
61
+ * @property {"instr"|"label"|"directive"|"equ"|"comment"|"blank"|"data"} kind
62
+ * @property {string} raw original text (sans the trailing da65 comment)
63
+ * @property {string} [label] leading label, e.g. "L8016" (colon stripped)
64
+ * @property {string} [mnemonic] lowercased, e.g. "lda"
65
+ * @property {string} [operand] e.g. "#$00", "$2000", "L8016", "$0200,x"
66
+ * @property {number} [addr] resolved CPU address of this instruction
67
+ */
68
+
69
+ const RE_EQU = /^\s*(\w+)\s*:=\s*(\$[0-9A-Fa-f]+)\s*$/;
70
+ const RE_DIRECTIVE = /^\s*\.(org|setcpu|byte|word|addr|res|segment)\b(.*)$/i;
71
+ // label: mnemonic operand (label optional; operand optional)
72
+ const RE_INSTR = /^(?:(\w+):)?\s+([a-z]{3})(?:\s+(\S.*?))?\s*$/;
73
+ // bare "label:" on its own line
74
+ const RE_LABEL_ONLY = /^(\w+):\s*$/;
75
+
76
+ /**
77
+ * Strip da65's trailing `; ....` comment (which carries the byte encoding + file
78
+ * offset + register annotation). We keep the structured data we need separately;
79
+ * the comment itself isn't valid to re-emit verbatim.
80
+ */
81
+ function stripComment(line) {
82
+ const i = line.indexOf(";");
83
+ return i === -1 ? line : line.slice(0, i).replace(/\s+$/, "");
84
+ }
85
+
86
+ /**
87
+ * Parse one da65 line into structured form. Robust to label-only lines, equ
88
+ * definitions (`L90AA := $90AA`), directives (`.org`, `.byte`, `.setcpu`), and
89
+ * the standard `label: mnemonic operand` instruction form.
90
+ * @param {string} rawLine
91
+ * @returns {ParsedLine}
92
+ */
93
+ export function parseDa65Line(rawLine) {
94
+ const noComment = stripComment(rawLine);
95
+ if (!noComment.trim()) return { kind: "blank", raw: "" };
96
+
97
+ const mEqu = noComment.match(RE_EQU);
98
+ if (mEqu) return { kind: "equ", raw: noComment, label: mEqu[1], operand: mEqu[2] };
99
+
100
+ const mDir = noComment.match(RE_DIRECTIVE);
101
+ if (mDir) {
102
+ const name = mDir[1].toLowerCase();
103
+ // .byte/.word/.addr/.res are DATA; .org/.setcpu are structural directives.
104
+ return { kind: /^(byte|word|addr|res)$/.test(name) ? "data" : "directive", raw: noComment };
105
+ }
106
+
107
+ const mLabel = noComment.match(RE_LABEL_ONLY);
108
+ if (mLabel) return { kind: "label", raw: noComment, label: mLabel[1] };
109
+
110
+ const mInstr = noComment.match(RE_INSTR);
111
+ if (mInstr) {
112
+ const mnem = mInstr[2].toLowerCase();
113
+ return {
114
+ kind: "instr", raw: noComment,
115
+ label: mInstr[1] || undefined,
116
+ mnemonic: mnem,
117
+ operand: mInstr[3] ? mInstr[3].trim() : undefined,
118
+ };
119
+ }
120
+ // Anything we can't classify is treated as data (safe; never mistranslated).
121
+ return { kind: "data", raw: noComment };
122
+ }
123
+
124
+ /**
125
+ * Detect a hardware-register access — the seam. Returns the register's low
126
+ * address (0x2000-0x401F) if the operand targets a NES PPU/APU register, else
127
+ * null. Only absolute operands count (`$2000`, `$2000,x`); immediates and
128
+ * zero-page never hit the register file.
129
+ * @param {string|undefined} operand
130
+ */
131
+ export function seamRegister(operand) {
132
+ if (!operand) return null;
133
+ // absolute (optionally indexed): $NNNN possibly followed by ,x / ,y
134
+ const m = operand.match(/^\$([0-9A-Fa-f]{3,4})(?:\s*,\s*[xy])?$/);
135
+ if (!m) return null;
136
+ const addr = parseInt(m[1], 16);
137
+ if (addr in NES_REGISTERS) return addr;
138
+ // $4014 (OAMDMA) and the $2000-$2007 / $4000-$4017 ranges are the seam even
139
+ // if a specific sub-address isn't named in the table.
140
+ if ((addr >= 0x2000 && addr <= 0x2007) || (addr >= 0x4000 && addr <= 0x4017)) return addr;
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Classify + translate ONE instruction line. Returns either:
146
+ * { ok:true, out: [asm lines] } — translated (1+ lines)
147
+ * { ok:false, reason, line } — refused (residue)
148
+ * The label (if any) is emitted as its own `label:` line so branch targets
149
+ * resolve regardless of how asar formats them.
150
+ * @param {ParsedLine} p
151
+ */
152
+ export function translateInstr(p) {
153
+ const out = [];
154
+ if (p.label) out.push(`${p.label}:`);
155
+ const mnem = p.mnemonic;
156
+ const operand = p.operand;
157
+
158
+ // Refuse the non-mechanical constructs up front.
159
+ if (mnem === "sed") {
160
+ return { ok: false, reason: "decimal-mode (sed): 65816 BCD edge-flag semantics differ from 6502", line: p.raw };
161
+ }
162
+ if (mnem === "jmp" && operand && operand.startsWith("(")) {
163
+ return { ok: false, reason: "indirect jump (jmp (addr)): target is computed — resolve with breakpoint({on:'jumptable'})", line: p.raw };
164
+ }
165
+
166
+ // The hardware seam: any PPU/APU register access becomes a seam call.
167
+ const reg = seamRegister(operand);
168
+ if (reg != null) {
169
+ out.push(...emitSeamAccess(mnem, operand, reg, p.raw));
170
+ return { ok: true, out };
171
+ }
172
+
173
+ // 1:1 pass-through for the documented, E-mode-identical instruction set.
174
+ if (PASSTHROUGH.has(mnem)) {
175
+ out.push(` ${mnem}${operand ? " " + operand : ""}`);
176
+ return { ok: true, out };
177
+ }
178
+
179
+ // brk / rti reach here — handled structurally elsewhere; for a generic
180
+ // function body they're unexpected, so flag rather than emit blindly.
181
+ if (mnem === "rti" || mnem === "brk") {
182
+ return { ok: false, reason: `${mnem}: interrupt-return/break needs explicit vector handling in v1`, line: p.raw };
183
+ }
184
+
185
+ // Unknown mnemonic = undocumented opcode da65 named, or a parse miss.
186
+ return { ok: false, reason: `unrecognized/undocumented opcode '${mnem}' — not a documented 6502 instruction`, line: p.raw };
187
+ }
188
+
189
+ /**
190
+ * Emit the seam call(s) for one PPU/APU access. v1 routes through stub
191
+ * subroutines (emitSeam) so the future NES-PPU-on-SNES runtime can drop in
192
+ * behind this boundary without touching the recompiler.
193
+ *
194
+ * Contract: A holds the value (for writes) / receives it (for reads); the
195
+ * register low byte is passed in X so one seam routine handles the whole file.
196
+ * @returns {string[]}
197
+ */
198
+ function emitSeamAccess(mnem, operand, reg, _rawForComment) {
199
+ const regName = NES_REGISTERS[reg] || `REG_${reg.toString(16)}`;
200
+ const lowByte = `#$${(reg & 0xff).toString(16).padStart(2, "0")}`;
201
+ const lines = [` ; seam: ${mnem} ${operand} (${regName})`];
202
+ if (mnem === "sta") {
203
+ // store A → register: A already holds the value.
204
+ lines.push(` ldx ${lowByte}`, ` jsr NES_PPU_WRITE`);
205
+ } else if (mnem === "stx" || mnem === "sty") {
206
+ lines.push(` txa`, ` ldx ${lowByte}`, ` jsr NES_PPU_WRITE`);
207
+ } else if (mnem === "lda" || mnem === "ldx" || mnem === "ldy" || mnem === "bit") {
208
+ lines.push(` ldx ${lowByte}`, ` jsr NES_PPU_READ`);
209
+ if (mnem === "bit") lines.push(` ; (bit set N/V from the read value)`);
210
+ } else {
211
+ // Read-modify-write or anything else against a register: refuse-safe — emit
212
+ // a marker the residue pass can flag. (Rare against MMIO; not in the pilot.)
213
+ lines.push(` ; UNTRANSLATED seam access: ${mnem} ${operand}`);
214
+ }
215
+ return lines;
216
+ }
217
+
218
+ /**
219
+ * The seam stub include (v1). Trap-to-rts for writes; reads return a sane
220
+ * constant. The ONE detail that makes the boot loop progress: the $2002
221
+ * (PPUSTATUS) read must return bit 7 set, so vblank-wait loops
222
+ * (`bit $2002 / bpl`) terminate — otherwise the port spins forever and never
223
+ * reaches its main loop. The real PPU shim (task #2) replaces these bodies.
224
+ */
225
+ export function emitSeam() {
226
+ return [
227
+ "; ── NES hardware seam (v1 stubs) ──────────────────────────────────────",
228
+ "; Writes trap to rts; reads return a safe constant. The PPUSTATUS read",
229
+ "; returns $80 (vblank bit set) so `bit $2002 / bpl` boot loops terminate.",
230
+ "; Replaced wholesale by the NES-PPU-on-SNES runtime (separate task).",
231
+ "NES_PPU_WRITE:",
232
+ " rts",
233
+ "NES_PPU_READ:",
234
+ " lda #$80 ; vblank set → boot wait-loops exit",
235
+ " rts",
236
+ "NES_APU_WRITE:",
237
+ " rts",
238
+ "NES_OAM_DMA:",
239
+ " rts",
240
+ "",
241
+ ].join("\n");
242
+ }
243
+
244
+ /**
245
+ * The asar LoROM wrapper: bank map, native→still-emulation preamble, the
246
+ * translated body, the seam include, and the reset/NMI/IRQ vectors. The CPU
247
+ * enters at RESET in emulation mode (E=1) so the 6502 logic runs as-is.
248
+ *
249
+ * @param {Object} a
250
+ * @param {string} a.body translated 65816 body (the functions)
251
+ * @param {string} a.resetLabel label the reset vector points at
252
+ * @param {string} [a.nmiLabel] label for the NMI vector (defaults to a stub)
253
+ * @param {boolean} [a.withShim] include + call the NES-PPU-on-SNES shim
254
+ * (nes_ppu_shim.asm). When true, the init preamble calls NES_SHIM_PRESENT
255
+ * (in native mode, before dropping to emulation) so the static boot picture
256
+ * the original ROM produced is drawn — turning the blank port into a real
257
+ * rendered screen.
258
+ */
259
+ export function emitMainAsm({ body, resetLabel, nmiLabel, withShim, withRuntime, nmiBody }) {
260
+ // Native NMI vector: with the phase-2 runtime it points at NES_RT_NMI (which
261
+ // flushes sprites then calls the game's NMI); otherwise the legacy stub/label.
262
+ const nmiVector = withRuntime ? "NES_RT_NMI" : (nmiLabel || "NMI_STUB");
263
+ return [
264
+ `; NES→SNES recompiled image (romdev emit backend, ${withRuntime ? "phase 2: live runtime" : "phase 1"}).`,
265
+ "; The 6502 game logic runs in 65816 EMULATION mode (E=1) unmodified.",
266
+ "lorom",
267
+ "",
268
+ "org $008000",
269
+ "RESET_ENTRY:",
270
+ " sei",
271
+ " clc",
272
+ " xce ; → native mode briefly to set up the stack...",
273
+ " rep #$30 ; A/X/Y 16-bit for init",
274
+ " ldx #$1FFF",
275
+ " txs ; SNES stack",
276
+ " sep #$30 ; back to 8-bit",
277
+ ...(withShim
278
+ ? [" jsr NES_SHIM_PRESENT ; draw the converted NES boot picture (native mode)"]
279
+ : []),
280
+ ...(withRuntime
281
+ ? [" jsr NES_RT_INIT ; enable vblank NMI + sprite setup (native mode)"]
282
+ : []),
283
+ " sec",
284
+ " xce ; → EMULATION mode: now the 6502 logic runs as-is",
285
+ ` jmp ${resetLabel}`,
286
+ "",
287
+ "; ── recompiled 6502 logic (emulation mode) ───────────────────────────",
288
+ body,
289
+ ...(withRuntime && nmiBody
290
+ ? ["", "; ── recompiled NES NMI handler (called from NES_RT_NMI each vblank) ──", nmiBody]
291
+ : []),
292
+ "",
293
+ // The emulation-mode IRQ/BRK still needs a target; keep a bare stub unless the
294
+ // caller provided a real NMI label for the legacy (non-runtime) path.
295
+ (withRuntime || nmiLabel) ? "" : "NMI_STUB:\n rti\n",
296
+ ...(withRuntime || !nmiLabel ? ["IRQ_STUB:\n rti\n"] : []),
297
+ // The seam (NES_PPU_WRITE/READ/...) lives in nes_seam.asm for the phase-1
298
+ // path; the phase-2 runtime DEFINES its own seam, so include only one.
299
+ // ORDER MATTERS: the runtime is CODE (no org of its own) so it must come
300
+ // BEFORE the shim — the shim ends with `org $028000` + 8KB of data, and any
301
+ // code after that would flow into bank $02 (wrong bank for the NMI vector +
302
+ // for `lda.l ...,x` tables). Seam/runtime first (bank $00), shim data last.
303
+ ...(withRuntime ? ["incsrc \"nes_ppu_runtime.asm\""] : ["incsrc \"nes_seam.asm\""]),
304
+ ...(withShim ? ["incsrc \"nes_ppu_shim.asm\""] : []),
305
+ "",
306
+ "; ── interrupt vectors (native + emulation) ───────────────────────────",
307
+ "; The recompiled game runs in EMULATION mode, so its NMI vectors through the",
308
+ "; EMULATION vector ($FFFA) — NOT the native one ($FFEA). Point BOTH at the",
309
+ "; runtime NMI so it fires regardless of mode.",
310
+ "org $00FFEA",
311
+ ` dw ${nmiVector} ; native NMI`,
312
+ "org $00FFFA",
313
+ ` dw ${nmiVector} ; emulation NMI (the game's actual vblank)`,
314
+ "org $00FFFC",
315
+ " dw RESET_ENTRY ; emulation RESET",
316
+ "org $00FFFE",
317
+ " dw IRQ_STUB ; emulation IRQ/BRK",
318
+ "",
319
+ ].join("\n");
320
+ }
321
+
322
+ /**
323
+ * Truncate a da65 listing at the first end-of-routine (rts/rti/rtl, or a bare
324
+ * `jmp`/`jmp (ind)` at the top level), keeping everything up to and including
325
+ * it. A flat full-PRG disasm renders data tables AFTER the first routine as
326
+ * bogus "code" (the M0 audit showed the $938D+ data band); slicing the first
327
+ * routine yields clean single-function input for M1. The leading
328
+ * directive/equ preamble is always kept.
329
+ *
330
+ * NOTE: the reset routine ends in `jmp L8000` (an infinite main loop), so the
331
+ * first bare `jmp` IS its terminator — correct cut point for the pilot.
332
+ * @param {string} da65Asm
333
+ * @returns {string}
334
+ */
335
+ export function sliceFirstRoutine(da65Asm) {
336
+ const lines = da65Asm.split("\n");
337
+ const kept = [];
338
+ let sawInstruction = false;
339
+ for (const line of lines) {
340
+ kept.push(line);
341
+ const p = parseDa65Line(line);
342
+ if (p.kind === "instr") {
343
+ sawInstruction = true;
344
+ const m = p.mnemonic;
345
+ // end-of-routine: rts/rti/rtl, or a bare jmp (the loop terminator)
346
+ if (m === "rts" || m === "rti" || m === "rtl" || m === "jmp") {
347
+ if (sawInstruction) break;
348
+ }
349
+ }
350
+ }
351
+ return kept.join("\n");
352
+ }
353
+
354
+ /**
355
+ * Full NES→SNES recompile of a da65 6502 listing into assemble-ready asar
356
+ * sources. This is the orchestrator the `disasm({target:'recompile'})` op
357
+ * calls: translate the body, derive the entry label, stub unresolved callees
358
+ * (M1 isolation), and emit the LoROM wrapper + seam include.
359
+ *
360
+ * @param {string} da65Asm the `asm` string from da65 / disasm({target:'rom'})
361
+ * @param {Object} [opts]
362
+ * @param {string} [opts.entry] override the reset-vector entry label
363
+ * @param {boolean} [opts.stubUndefined=true] stub callees not defined in the
364
+ * slice (needed when recompiling a single function in isolation; set false
365
+ * once the whole reachable graph is translated)
366
+ * @param {boolean} [opts.withShim=false] emit a call to + incsrc of the
367
+ * NES-PPU-on-SNES shim (the caller supplies nes_ppu_shim.asm separately)
368
+ * @param {boolean} [opts.withRuntime=false] phase 2: wire the per-frame runtime
369
+ * (the caller supplies nes_ppu_runtime.asm + sets the NMI vector). Requires
370
+ * nmiDa65Asm to give the runtime a game NMI handler to call.
371
+ * @param {string} [opts.nmiDa65Asm] da65 listing of the NES NMI handler (sliced
372
+ * from the $FFFA vector). Translated as a second body the runtime NMI calls.
373
+ * @returns {{ mainAsm: string, seamAsm: string, residue: Array<{reason,line}>,
374
+ * entry: string, nmiEntry: string|null, instrCount: number,
375
+ * seamCount: number, stubbed: string[] }}
376
+ */
377
+ 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,
435
+ });
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
+ }
446
+
447
+ /**
448
+ * Translate a block of da65 6502 asm text (one function or the whole listing)
449
+ * into 65816. Returns the translated body, the collected equ definitions, and a
450
+ * residue list of refused lines.
451
+ *
452
+ * @param {string} da65Asm the `asm` string from disasm({target:'rom'})
453
+ * @returns {{ body: string, equs: string[], residue: Array<{reason,line}>, instrCount: number, seamCount: number }}
454
+ */
455
+ export function translateBody(da65Asm) {
456
+ const lines = da65Asm.split("\n");
457
+ const bodyLines = [];
458
+ const equs = [];
459
+ const residue = [];
460
+ let instrCount = 0;
461
+ let seamCount = 0;
462
+ // The reset vector must target the FIRST translated instruction. da65 only
463
+ // labels branch targets, so a fall-through entry (the common case — a reset
464
+ // routine that opens `sei / cld / ...`) is UNLABELED. Guarantee a label on the
465
+ // first instruction: use its own label if it has one, else inject a synthetic
466
+ // RECOMPILE_ENTRY. Without this, entryLabel() picked the first *labeled* line
467
+ // (a later branch target) and the reset skipped the routine's opening setup.
468
+ let entry = null;
469
+ const ENTRY_LABEL = "RECOMPILE_ENTRY";
470
+
471
+ for (const raw of lines) {
472
+ const p = parseDa65Line(raw);
473
+ switch (p.kind) {
474
+ case "blank":
475
+ case "comment":
476
+ break;
477
+ case "directive":
478
+ // drop .org/.setcpu — the wrapper owns layout/cpu.
479
+ break;
480
+ case "equ":
481
+ equs.push(`${p.label} = ${p.operand}`);
482
+ break;
483
+ case "label":
484
+ bodyLines.push(`${p.label}:`);
485
+ break;
486
+ case "data":
487
+ // A .byte inside the translated stream is either a data table caught in
488
+ // the function tail or an undocumented opcode. Flag as residue so it's
489
+ // never silently emitted as wrong code; the caller decides cut points.
490
+ residue.push({ reason: "data/.byte in code stream (data table or undocumented opcode)", line: p.raw.trim() });
491
+ break;
492
+ case "instr": {
493
+ const r = translateInstr(p);
494
+ if (r.ok) {
495
+ instrCount++;
496
+ if (entry == null) {
497
+ // First real instruction → fix the entry label.
498
+ if (p.label) {
499
+ entry = p.label;
500
+ } else {
501
+ entry = ENTRY_LABEL;
502
+ bodyLines.push(`${ENTRY_LABEL}:`);
503
+ }
504
+ }
505
+ if (r.out.some((l) => l.includes("NES_PPU_") || l.includes("seam:"))) seamCount++;
506
+ bodyLines.push(...r.out);
507
+ } else {
508
+ residue.push({ reason: r.reason, line: r.line.trim() });
509
+ // Even a refused first instruction must anchor the entry, so the reset
510
+ // vector lands at the top of the routine rather than a later label.
511
+ if (entry == null) {
512
+ entry = p.label || ENTRY_LABEL;
513
+ if (!p.label) bodyLines.push(`${ENTRY_LABEL}:`);
514
+ }
515
+ // Emit a visible marker so the asm still shows where logic was dropped.
516
+ if (p.label) bodyLines.push(`${p.label}:`);
517
+ bodyLines.push(` ; UNTRANSLATED: ${p.raw.trim()} (${r.reason})`);
518
+ }
519
+ break;
520
+ }
521
+ default:
522
+ break;
523
+ }
524
+ }
525
+ return { body: bodyLines.join("\n"), equs, residue, instrCount, seamCount, entry };
526
+ }
527
+
528
+ /**
529
+ * Find labels REFERENCED by jsr/jmp/branch in the body that are not DEFINED
530
+ * (no `label:`) and not declared as an equ. In a single-function slice (M1)
531
+ * these are callees in other functions; stubbing them lets the slice assemble
532
+ * and run in isolation. M2 replaces stubs with the real translated functions.
533
+ * @param {string} body
534
+ * @param {string[]} equs equ lines ("L90AA = $90AA")
535
+ * @returns {string[]} undefined label names
536
+ */
537
+ export function findUndefinedLabels(body, equs = []) {
538
+ const defined = new Set();
539
+ const equNames = new Set(equs.map((e) => e.split(/\s*=/)[0].trim()));
540
+ const referenced = new Set();
541
+ for (const line of body.split("\n")) {
542
+ const def = line.match(/^(\w+):/);
543
+ if (def) defined.add(def[1]);
544
+ // jsr/jmp/branch operand that is a bare label (Lxxxx or a name), not $hex/#imm
545
+ const ref = line.match(/^\s+(?:jsr|jmp|b\w\w)\s+([A-Za-z_]\w*)\s*$/);
546
+ if (ref) referenced.add(ref[1]);
547
+ }
548
+ return [...referenced].filter(
549
+ (n) => !defined.has(n) && !equNames.has(n) && !SEAM_LABELS.has(n),
550
+ );
551
+ }
552
+
553
+ /** Labels defined in the seam include — never stub these (they'd redefine). */
554
+ const SEAM_LABELS = new Set([
555
+ "NES_PPU_WRITE", "NES_PPU_READ", "NES_APU_WRITE", "NES_OAM_DMA",
556
+ ]);
557
+
558
+ /**
559
+ * The label on the FIRST translated instruction — the entry point the reset
560
+ * vector should target. da65 names the entry `L8000:` (or `reset:` with
561
+ * untilReturn aliasing). Returns null if the body has no labeled entry, in
562
+ * which case the caller should inject one.
563
+ * @param {string} body
564
+ */
565
+ export function entryLabel(body) {
566
+ for (const line of body.split("\n")) {
567
+ const m = line.match(/^(\w+):/);
568
+ if (m) return m[1];
569
+ }
570
+ return null;
571
+ }
572
+
573
+ /**
574
+ * Emit `label: rts` stubs for a list of undefined callee labels (M1 isolation).
575
+ * @param {string[]} names
576
+ */
577
+ export function emitStubs(names) {
578
+ if (!names.length) return "";
579
+ return [
580
+ "; ── unresolved callee stubs (M1 single-function isolation) ──",
581
+ ...names.map((n) => `${n}:\n rts`),
582
+ "",
583
+ ].join("\n");
584
+ }
@@ -444,9 +444,21 @@ export function decodeWithDevice(code, platform) {
444
444
  return { ...decoded, device };
445
445
  }
446
446
 
447
- /** Format a raw ADDR:VAL[:COMPARE] code from decoded parts (hex, no 0x). */
447
+ /** Format a raw ADDR:VAL[:COMPARE] code from decoded parts (hex, no 0x).
448
+ * The ADDRESS is padded to a minimum of 4 hex digits. This is NOT cosmetic:
449
+ * libretro cheat parsers (verified on fceumm) treat a short `AA:VV` RAM code as
450
+ * inert — it parses but never binds (`apply` then falsely reports success). A
451
+ * zero-page address like $32 must be emitted as `0032:09` to actually poke; the
452
+ * 2-digit `32:09` is silently dropped. ROM addresses ($8000+) are already 4+
453
+ * digits, so padding only fixes the low-RAM case and never changes a working
454
+ * code. The VALUE stays 2 digits (`0009` 4-digit values also break the parser).
455
+ * Addresses wider than 16 bits (e.g. Genesis $FFxxxx work RAM) keep their full
456
+ * width, rounded up to an even number of digits. */
448
457
  export function encodeRaw({ address, value, compare }) {
449
- const addrHex = (address >>> 0).toString(16).toUpperCase();
458
+ const rawHex = (address >>> 0).toString(16).toUpperCase();
459
+ // pad to >=4 digits, and to an even digit count so it's a whole number of bytes
460
+ const width = Math.max(4, rawHex.length + (rawHex.length & 1));
461
+ const addrHex = rawHex.padStart(width, "0");
450
462
  const valHex = (value & 0xFF).toString(16).toUpperCase().padStart(2, "0");
451
463
  return compare != null
452
464
  ? `${addrHex}:${valHex}:${(compare & 0xFF).toString(16).toUpperCase().padStart(2, "0")}`