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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ 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.42.0 — 2026-06-24
8
+
9
+ ### v0.41.0 feedback batch + the NES→SNES port engine
10
+
11
+ A round driven by RE-session feedback (cheats, ergonomics, the human-playtest
12
+ eviction case) plus the flagship port-engine work.
13
+
14
+ #### Fixed — `cheats` raw RAM codes were inert
15
+
16
+ `cheats({op:'make'})` for a sub-`$0100` RAM address emitted the short `32:09`
17
+ form, which libretro cores PARSE but never bind — and `apply` then falsely
18
+ reported `applied:true`. `encodeRaw` now pads the address to a binding width
19
+ (`0032:09`); `apply` re-encodes a hand-typed short code too. Verified on fceumm.
20
+
21
+ #### New — hex-string forms on address params
22
+
23
+ A hex literal in a JSON arg (`address: 0xC06C`) was a hard parse error. All
24
+ address-like params (`address`/`offset`/`pc`/`compare`/…) now accept the STRING
25
+ forms `"0xC06C"` / `"$C06C"` / decimal strings, coerced before validation —
26
+ centralized so every tool gets it.
27
+
28
+ #### New — `catalog({op:'status'}).capabilities`
29
+
30
+ A map of which debug ops the loaded core/toolchain implement (pcBreakpoint,
31
+ watchpointExact, rangeWatch, cheats, da65Toolchain, …) so an agent picks a
32
+ working trace strategy up front instead of probing by failure.
33
+
34
+ #### New — `callStack` on breakpoint hits (13 of 14 platforms)
35
+
36
+ `breakpoint({on:'pc'|'write'})` hits carry a decoded call stack: the server walks
37
+ the stack from the captured stack pointer and returns each caller PC. Covers the
38
+ 6502 family (JSR-opcode validated), m68k (Genesis — 4-byte BE return), and Z80/
39
+ SM83 (SMS/GG/MSX/GB/GBC — 2-byte LE return). GBA (ARM) is the lone exclusion: its
40
+ calls return through the link register, not the stack.
41
+
42
+ #### New — `references` finds inline jump-table / trampoline call sites
43
+
44
+ When no `jsr/jmp/branch` names a CODE address (it's reached via a computed jump),
45
+ `references` now scans the raw ROM for the address as a 16-bit pointer (LE/BE) and
46
+ reports `tableHits`. Per-platform header skip (NES/SNES/7800/Lynx); the 6502
47
+ RTS-trick `addr-1` form is scanned only on the 6502 family.
48
+
49
+ #### New — `disasm({target:'pointerTable'})` (all mapped platforms)
50
+
51
+ Static index→handler decode of a jump/pointer table: contiguous `dw`, SPLIT
52
+ lo/hi arrays at two bases, the RTS-trick (`+1`), and a REVERSE lookup (handler →
53
+ dispatch index). The static complement to the live `breakpoint({on:'jumptable'})`.
54
+ Works on every platform with an address mapper (nes/snes/sms/gg/gb/gbc/2600/7800/
55
+ c64/genesis); the endian default follows the CPU (Genesis/m68k → BE).
56
+
57
+ #### New — playtest eviction survivability
58
+
59
+ While a playtest window is open, romdev rolls a `.state` to disk every ~15s (and
60
+ on F2), so a session eviction can't lose a human's manual progress — the recovery
61
+ hint points at `state({op:'load', path})`. Surfaced in `playtest({op:'status'})`.
62
+
63
+ #### Leaner schemas
64
+
65
+ The last inline 62-value region enum in the watch/breakpoint tool schemas is now
66
+ a runtime-validated string — the full enum lives once on `memory`'s `region`.
67
+
68
+ #### Port engine — NES→SNES static recompile renders + plays
69
+
70
+ `disasm({target:'recompile'})` now draws the original ROM's screen on SNES
71
+ (`withShim`, phase-1 static — fixed the `cpx.w` upload bug) and animates sprites
72
+ + runs the game's NMI each vblank (`withRuntime`, phase-2 live). The recompiler
73
+ follows the real reset vector and anchors the entry to the routine's first
74
+ instruction. (Plus the `frame` port-compare oracles: compareRam/compareRender/
75
+ findDiverge/portStatus + side-by-side two-core capture.)
76
+
7
77
  ## 0.41.0 — 2026-06-12
8
78
 
9
79
  ### RE engine round — bank-aware decompile, live jumptable recovery, readable 6502 output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",
@@ -80,6 +80,8 @@
80
80
  "files": [
81
81
  "src",
82
82
  "!src/**/*.test.js",
83
+ "!src/cores/wasm/**",
84
+ "!src/toolchains/*/wasm/**",
83
85
  "examples",
84
86
  "AGENTS.md",
85
87
  "README.md",
@@ -12,7 +12,7 @@
12
12
  // resolved by the existing disasm mappers — analysis here is whole-file.
13
13
  import { readFile } from "node:fs/promises";
14
14
  import path from "node:path";
15
- import { runRizin, runRizinJson, RIZIN_ARCH } from "./rizin.js";
15
+ import { runRizinJson, RIZIN_ARCH } from "./rizin.js";
16
16
  import { decompileFunction, SLEIGH_LANGID } from "./decompile.js";
17
17
  import { registersForPlatform } from "../platforms/common/registers.js";
18
18
 
@@ -0,0 +1,198 @@
1
+ // Call-stack reconstruction from a CPU register snapshot + the stack RAM. Turns
2
+ // "who called this routine?" from a captureMemory read + several lines of by-hand,
3
+ // off-by-one-prone return-address pointer math into one decoded field on a
4
+ // breakpoint hit. (v0.41.0 feedback note 005444 N1.)
5
+ //
6
+ // The ISA-specific part is the return-address convention. Phase 1 covers the
7
+ // 6502 family (the dominant RE target); the structure generalizes to Z80 / SM83 /
8
+ // m68k by adding a decoder. We DON'T claim a frame is real beyond what the raw
9
+ // stack bytes say — a 6502 stack also holds saved registers and data pushes, so
10
+ // each candidate is validated (the byte two-before the return target must be a
11
+ // `jsr` opcode $20) and flagged `confident` accordingly. Unvalidated candidates
12
+ // are still returned (some callers use jmp-based trampolines) but marked.
13
+ //
14
+ // Plain JS ESM + JSDoc.
15
+
16
+ /** 6502 JSR opcode. A real call frame's return address points just past it. */
17
+ const JSR_6502 = 0x20;
18
+
19
+ /**
20
+ * Decode a 6502-family call stack from the stack page.
21
+ *
22
+ * 6502 stack model: page 1 ($0100-$01FF), SP points to the NEXT-FREE slot, so the
23
+ * topmost pushed byte is at $0100 + ((S + 1) & 0xFF). JSR pushes the return
24
+ * address (the address of its LAST operand byte = JSR_addr + 2) high byte first
25
+ * then low byte — so on the stack the low byte sits at the lower address. To
26
+ * recover the caller: read a little-endian word off the stack, subtract 2 (→ the
27
+ * JSR instruction), and confirm the byte there is $20.
28
+ *
29
+ * @param {number} sp the S register (0x00-0xFF)
30
+ * @param {Uint8Array} stackPage the 256 bytes of $0100-$01FF
31
+ * @param {(cpuAddr:number)=>(number|null)} readByteAt read one byte at a CPU
32
+ * address (used to validate the $20 opcode); may return null if unmapped.
33
+ * @param {number} [maxDepth=8]
34
+ * @returns {Array<{ returnAddr:number, callerPc:number, confident:boolean }>}
35
+ */
36
+ export function decode6502Backtrace(sp, stackPage, readByteAt, maxDepth = 8) {
37
+ const frames = [];
38
+ // Walk upward from the first occupied slot toward $01FF, reading LE words.
39
+ let idx = (sp + 1) & 0xff; // index into the page of the topmost pushed byte
40
+ while (frames.length < maxDepth && idx <= 0xfe) {
41
+ const lo = stackPage[idx];
42
+ const hi = stackPage[idx + 1];
43
+ if (lo == null || hi == null) break;
44
+ const returnAddr = (hi << 8) | lo;
45
+ const callerPc = (returnAddr - 2) & 0xffff;
46
+ let confident = false;
47
+ if (returnAddr >= 0x8000) {
48
+ // a plausible code return address; validate the JSR opcode if we can read it
49
+ const op = readByteAt ? readByteAt(callerPc) : null;
50
+ confident = op === JSR_6502;
51
+ }
52
+ if (confident) {
53
+ frames.push({ returnAddr, callerPc, confident });
54
+ } else if (returnAddr >= 0x8000 && frames.every((f) => !f.confident)) {
55
+ // keep plausible-but-unvalidated frames only while we haven't yet locked
56
+ // onto a confident call chain (some callers use jmp-trampolines, no $20).
57
+ frames.push({ returnAddr, callerPc, confident });
58
+ } else if (frames.some((f) => f.confident)) {
59
+ // we had a confident chain and hit a non-call word ($FF padding, saved
60
+ // data) → we've walked off the live frames. Stop cleanly.
61
+ break;
62
+ }
63
+ idx += 2;
64
+ }
65
+ // If any frame validated as a real JSR, drop trailing unconfident noise so the
66
+ // caller sees the trustworthy chain. Otherwise return the best-effort guesses.
67
+ const anyConfident = frames.some((f) => f.confident);
68
+ return anyConfident ? frames.filter((f) => f.confident) : frames;
69
+ }
70
+
71
+ /** Parse a hex register value that may carry a "$"/"0x" prefix → number, or NaN. */
72
+ function regNum(v) {
73
+ return parseInt(String(v ?? "").replace(/^\$|^0x/i, ""), 16);
74
+ }
75
+
76
+ /**
77
+ * Decode a stack of 16-bit LE return addresses (Z80 `call` / SM83 `call`): the
78
+ * stack grows DOWN and SP points at the low byte of the topmost return address;
79
+ * each `call` pushed a 2-byte LE return = the address of the instruction AFTER
80
+ * the call. We can't cheaply recover the call instruction's own length (Z80 call
81
+ * is 3 bytes, but a computed jump-in differs), so callerPc IS the return address
82
+ * and we mark it `confident` when it lands in a plausible code range. SP-relative,
83
+ * so the caller supplies a word reader (handles the platform's RAM mapping).
84
+ *
85
+ * @param {number} sp
86
+ * @param {(cpuAddr:number)=>(number|null)} readWordLE read a 16-bit LE word
87
+ * @param {number} [maxDepth=8]
88
+ * @param {number} [codeMin=0x0000] return addresses below this are treated as data
89
+ */
90
+ function decode16BitStack(sp, readWordLE, maxDepth = 8, codeMin = 0x0150) {
91
+ const frames = [];
92
+ let addr = sp & 0xffff;
93
+ for (let i = 0; i < maxDepth; i++) {
94
+ const ret = readWordLE(addr);
95
+ if (ret == null) break;
96
+ const confident = ret >= codeMin && ret <= 0xffff;
97
+ if (confident) {
98
+ frames.push({ returnAddr: ret, callerPc: ret, confident: true });
99
+ } else if (frames.every((f) => !f.confident)) {
100
+ frames.push({ returnAddr: ret, callerPc: ret, confident: false });
101
+ } else {
102
+ break; // had a confident chain, hit a non-code word → off the frames
103
+ }
104
+ addr = (addr + 2) & 0xffff;
105
+ }
106
+ const anyConfident = frames.some((f) => f.confident);
107
+ return anyConfident ? frames.filter((f) => f.confident) : frames;
108
+ }
109
+
110
+ /**
111
+ * Decode an m68k call stack. `jsr`/`bsr` push a 4-byte (longword) return address;
112
+ * the stack grows down and A7 (SP) points at the topmost return longword (stored
113
+ * big-endian). The return address is the instruction AFTER the jsr/bsr; we report
114
+ * it as callerPc (recovering the call's own length needs a disasm pass). Genesis
115
+ * RAM is $FF0000-$FFFFFF (mirrored), so SP is a 24-bit bus address.
116
+ *
117
+ * @param {number} sp
118
+ * @param {(cpuAddr:number)=>(number|null)} readLongBE read a 32-bit BE longword
119
+ * @param {number} [maxDepth=8]
120
+ */
121
+ function decodeM68kStack(sp, readLongBE, maxDepth = 8) {
122
+ const frames = [];
123
+ let addr = sp >>> 0;
124
+ for (let i = 0; i < maxDepth; i++) {
125
+ const ret = readLongBE(addr);
126
+ if (ret == null) break;
127
+ // Plausible m68k code address: even, inside the 24-bit address space, and not
128
+ // obviously garbage ($00000000 / $FFFFFFFF). ROM is $000000+; RAM is $FF0000+.
129
+ const a = ret & 0xffffff;
130
+ const confident = (a & 1) === 0 && a !== 0 && a !== 0xffffff && a < 0x1000000;
131
+ if (confident) {
132
+ frames.push({ returnAddr: a, callerPc: a, confident: true });
133
+ } else if (frames.every((f) => !f.confident)) {
134
+ frames.push({ returnAddr: a, callerPc: a, confident: false });
135
+ } else {
136
+ break;
137
+ }
138
+ addr = (addr + 4) >>> 0;
139
+ }
140
+ const anyConfident = frames.some((f) => f.confident);
141
+ return anyConfident ? frames.filter((f) => f.confident) : frames;
142
+ }
143
+
144
+ /**
145
+ * Build a backtrace from a register snapshot + a stack reader, dispatching on CPU
146
+ * family. Returns null if the platform isn't supported or the snapshot lacks a
147
+ * stack pointer — callers treat null as "no backtrace available", not an error.
148
+ *
149
+ * Coverage: 6502 family (nes/2600/7800/c64/lynx/pce), m68k (genesis), Z80
150
+ * (sms/gg/msx), SM83 (gb/gbc) — 13 of 14. GBA (ARM) is intentionally excluded:
151
+ * ARM's BL leaves the return address in the LINK REGISTER, not on the stack, so a
152
+ * stack walk doesn't recover the call chain without frame-pointer/unwind analysis.
153
+ *
154
+ * @param {Object} opts
155
+ * @param {string} opts.platform
156
+ * @param {Object} opts.regs the `named` register map (hex strings)
157
+ * @param {(region:string, offset:number, length:number)=>Uint8Array} opts.readMemory
158
+ * @param {(cpuAddr:number)=>(number|null)} [opts.readByteAt] validate the 6502 JSR opcode
159
+ * @param {(cpuAddr:number, bytes:number)=>(number|null)} [opts.readCpuWord] read
160
+ * a little-endian word at a CPU address (Z80/SM83 stacks live in work RAM at the
161
+ * SP, not a fixed page) — required for z80/sm83.
162
+ * @param {(cpuAddr:number)=>(number|null)} [opts.readCpuLongBE] read a 32-bit
163
+ * big-endian longword at a 68K bus address — required for genesis.
164
+ * @param {number} [opts.maxDepth=8]
165
+ * @returns {{ frames: Array, isa: string } | null}
166
+ */
167
+ export function buildBacktrace({ platform, regs, readMemory, readByteAt, readCpuWord, readCpuLongBE, maxDepth = 8 }) {
168
+ if (!regs) return null;
169
+ const SIXTYFIVE_OH_TWO = new Set(["nes", "atari2600", "atari7800", "c64", "lynx", "pce"]);
170
+ const Z80_FAMILY = new Set(["sms", "gg", "msx", "gb", "gbc"]); // z80 + sm83: same 2-byte LE call frame
171
+ if (SIXTYFIVE_OH_TWO.has(platform)) {
172
+ const sp = regNum(regs.s);
173
+ if (Number.isNaN(sp)) return null;
174
+ let stackPage;
175
+ try { stackPage = readMemory("system_ram", 0x0100, 0x100); } catch { return null; }
176
+ if (stackPage.length < 0x100) {
177
+ const full = new Uint8Array(0x100);
178
+ full.set(stackPage.subarray(0, 0x100));
179
+ stackPage = full;
180
+ }
181
+ const frames = decode6502Backtrace(sp, stackPage, readByteAt, maxDepth);
182
+ return { isa: "6502", frames };
183
+ }
184
+ if (Z80_FAMILY.has(platform)) {
185
+ const sp = regNum(regs.sp);
186
+ if (Number.isNaN(sp) || !readCpuWord) return null;
187
+ const readWordLE = (a) => readCpuWord(a, 2);
188
+ const frames = decode16BitStack(sp, readWordLE, maxDepth);
189
+ return { isa: platform === "gb" || platform === "gbc" ? "sm83" : "z80", frames };
190
+ }
191
+ if (platform === "genesis") {
192
+ const sp = regNum(regs.sp);
193
+ if (Number.isNaN(sp) || !readCpuLongBE) return null;
194
+ const frames = decodeM68kStack(sp, readCpuLongBE, maxDepth);
195
+ return { isa: "m68k", frames };
196
+ }
197
+ return null; // gba (ARM, link-register calls) — not a stack walk
198
+ }
@@ -0,0 +1,220 @@
1
+ // NES-PPU-on-SNES PER-FRAME runtime — phase 2. The static shim
2
+ // (nes-ppu-shim.js) draws the boot picture ONCE then stubs the PPU seam; this
3
+ // runtime keeps it LIVE: every vblank it flushes the game's sprite state to SNES
4
+ // OAM and runs the game's own NMI handler, so animation actually moves. Pairs
5
+ // with recompile-65816.js (which wires the seam + NMI vector to these labels).
6
+ //
7
+ // WHY THIS IS TRACTABLE (not a full NES PPU in 65816):
8
+ // The recompiled NES logic runs in 65816 EMULATION mode, so the game's NES RAM
9
+ // IS SNES low RAM at the same addresses. A typical NES game builds a 256-byte
10
+ // "shadow OAM" in RAM (commonly $0200) and copies it to the PPU each frame via
11
+ // OAMDMA (`sta $4014`, A = source page). That shadow OAM therefore already
12
+ // exists in SNES WRAM — the runtime just (a) learns the page from the OAMDMA
13
+ // write, and (b) each vblank converts those 64 NES sprites to SNES OAM format
14
+ // and DMAs them in. Sprites are the dominant per-frame change, so this single
15
+ // feature turns "static screenshot" into "the sprites move."
16
+ //
17
+ // SCOPE (phase 2): sprites (OAM) + running the game's NMI each vblank + honoring
18
+ // PPUCTRL NMI-enable. Background stays as the shim uploaded it (live $2007
19
+ // nametable streaming is phase 3). APU is still no-op.
20
+ //
21
+ // FORMATS:
22
+ // NES OAM (4 bytes/sprite): [0]=Y, [1]=tile, [2]=attr, [3]=X
23
+ // attr: bit7=V-flip bit6=H-flip bit5=priority bits0-1=palette(4-7)
24
+ // SNES low OAM (4 bytes): [0]=X, [1]=Y, [2]=tile, [3]=attr
25
+ // attr: bit0=tile-bit8 bits1-3=palette bits4-5=priority bit6=H bit7=V
26
+ // SNES high OAM (2 bits/sprite): X-bit8 + size. Phase 2 writes it all-zero
27
+ // (X<256, small sprites).
28
+ // Conversion (verified on snes9x): SNES.X=NES.X, SNES.Y=NES.Y, SNES.tile=NES.tile,
29
+ // SNES.attr = (NES.attr & $C0) | ((NES.attr & 3) << 1).
30
+ //
31
+ // Plain JS ESM + JSDoc.
32
+
33
+ /**
34
+ * Fixed runtime state in SNES low RAM ($0010-$0012 — a small window most NES
35
+ * games leave free in zero page; the NMI also saves/restores DP scratch $00).
36
+ */
37
+ export const RT_RAM = {
38
+ OAMDMA_PAGE: "$0010", // high byte of the shadow-OAM source page (set by $4014)
39
+ };
40
+
41
+ /** The $7E WRAM staging buffer for the SNES OAM image (288 bytes: 256 + 32). */
42
+ const OAM_STAGE = 0x1000;
43
+
44
+ /**
45
+ * Emit the phase-2 runtime as asar source: NES_RT_INIT (enable NMI + sprite
46
+ * setup), NES_RT_NMI (the per-vblank flush + game NMI), NES_RT_FLUSH_SPRITES
47
+ * (the verified OAM conversion+DMA), and the seam bodies that maintain the
48
+ * OAMDMA-page mirror. recompile-65816 includes this INSTEAD of the v1 seam stub
49
+ * when the runtime is on, points the native NMI vector at NES_RT_NMI, and calls
50
+ * NES_RT_INIT from RESET_ENTRY.
51
+ *
52
+ * @param {Object} opts
53
+ * @param {string|null} opts.nesNmiLabel the translated NES NMI handler's entry
54
+ * label (the game's per-frame logic). If null, only the sprite flush runs.
55
+ * @param {number} [opts.oamPage=0x02] default shadow-OAM page until $4014 sets
56
+ * it (0x02 = $0200, the de-facto standard).
57
+ * @returns {string} asar source.
58
+ */
59
+ export function emitPpuRuntime({ nesNmiLabel = null, oamPage = 0x02 } = {}) {
60
+ const stageHex = "$" + OAM_STAGE.toString(16).toUpperCase();
61
+ const highHex = "$" + (OAM_STAGE + 0x100).toString(16).toUpperCase();
62
+
63
+ // We are already in EMULATION mode here (NES_RT_NMI switched back before this),
64
+ // so just call the game's 6502 NMI body directly — no mode switch.
65
+ const callGameNmi = nesNmiLabel
66
+ ? [
67
+ " ; run the game's own NMI handler (its per-frame logic), in E-mode",
68
+ ` jsr ${nesNmiLabel}`,
69
+ ]
70
+ : [" ; (no translated NES NMI handler — sprite flush only)"];
71
+
72
+ return [
73
+ "; ── NES-PPU-on-SNES per-frame runtime (phase 2: live sprites) ─────────",
74
+ "",
75
+ "!INIDISP = $2100",
76
+ "!OBSEL = $2101",
77
+ "!OAMADDL = $2102",
78
+ "!OAMADDH = $2103",
79
+ "!TM = $212C",
80
+ "!NMITIMEN = $4200",
81
+ "!MDMAEN = $420B",
82
+ "!DMAP0 = $4300",
83
+ "!BBAD0 = $4301",
84
+ "!A1T0L = $4302",
85
+ "!A1B0 = $4304",
86
+ "!DAS0L = $4305",
87
+ "",
88
+ "; called from RESET_ENTRY (native mode) before the game's reset runs.",
89
+ "NES_RT_INIT:",
90
+ " php",
91
+ " sep #$20",
92
+ " rep #$10",
93
+ ` lda #$${oamPage.toString(16).padStart(2, "0")}`,
94
+ ` sta ${RT_RAM.OAMDMA_PAGE} ; default shadow-OAM page until $4014 sets it`,
95
+ " lda #$00",
96
+ " sta !OBSEL ; 8x8 sprites, sprite tiles share VRAM word $0000",
97
+ " lda #$10",
98
+ " sta !TM ; enable OBJ (sprites) on the main screen",
99
+ " lda #$80",
100
+ " sta !NMITIMEN ; enable the vblank NMI (the game's heartbeat)",
101
+ " plp",
102
+ " rts",
103
+ "",
104
+ "; The per-vblank NMI handler. The recompiled game runs in 65816 EMULATION",
105
+ "; mode, so this is entered with e=1 (8-bit regs, page-1 stack, 6502-style NMI",
106
+ "; frame on the stack). We save the 8-bit regs, drop to NATIVE mode for the",
107
+ "; 16-bit-index sprite flush, come back to emulation, run the game's NMI, then",
108
+ "; rti in emulation mode so the pushed PC/P frame returns correctly.",
109
+ "NES_RT_NMI:",
110
+ " pha",
111
+ " phx",
112
+ " phy",
113
+ " ; save bank + DP, then go native for the flush.",
114
+ " phb",
115
+ " phd",
116
+ " clc",
117
+ " xce ; e=1 → native (saves e in carry)",
118
+ " pea $0000",
119
+ " pld ; DP = $0000 for scratch + low-RAM reads",
120
+ " sep #$30",
121
+ "",
122
+ " jsr NES_RT_FLUSH_SPRITES",
123
+ "",
124
+ " sec",
125
+ " xce ; native → back to EMULATION mode",
126
+ "",
127
+ ...callGameNmi,
128
+ "",
129
+ " pld",
130
+ " plb",
131
+ " ply",
132
+ " plx",
133
+ " pla",
134
+ " rti ; emulation-mode rti (pops P + 16-bit PC)",
135
+ "",
136
+ "; NES shadow OAM → SNES OAM. Verified on snes9x. Phase 2 reads the shadow OAM",
137
+ "; from $0200 (the de-facto-standard page); the OAMDMA seam records the actual",
138
+ "; page in OAMDMA_PAGE, and the init seeds it to $02. X walks the NES sprites,",
139
+ "; Y the SNES low table — both step by 4 in lockstep.",
140
+ "NES_RT_FLUSH_SPRITES:",
141
+ " php",
142
+ " sep #$20",
143
+ " rep #$10",
144
+ " lda #$7E",
145
+ " pha",
146
+ " plb ; DBR = $7E for the staging stores",
147
+ " ldx #$0000 ; NES sprite byte index (0,4,..252) — DBR-independent",
148
+ " ldy #$0000 ; SNES low-table byte index",
149
+ "-",
150
+ " lda $0200,x ; NES Y ($0200 is bank-0 low RAM == $7E:0200)",
151
+ ` sta ${stageHex}+1,y ; SNES [1] = Y`,
152
+ " lda $0201,x ; NES tile",
153
+ ` sta ${stageHex}+2,y ; SNES [2] = tile`,
154
+ " lda $0203,x ; NES X",
155
+ ` sta ${stageHex}+0,y ; SNES [0] = X`,
156
+ " lda $0202,x ; NES attr",
157
+ " pha",
158
+ " and #$C0 ; V/H flip bits (already aligned)",
159
+ " sta $00",
160
+ " pla",
161
+ " and #$03 ; NES palette 0-3",
162
+ " asl ; → SNES palette bits 1-3",
163
+ " ora $00",
164
+ ` sta ${stageHex}+3,y ; SNES [3] = attr`,
165
+ " inx",
166
+ " inx",
167
+ " inx",
168
+ " inx",
169
+ " iny",
170
+ " iny",
171
+ " iny",
172
+ " iny",
173
+ " cpy.w #$0100 ; 64 sprites",
174
+ " bne -",
175
+ " ; high table (32 bytes) = all zero",
176
+ " ldx #$0000",
177
+ " lda #$00",
178
+ "-",
179
+ ` sta ${highHex},x`,
180
+ " inx",
181
+ " cpx.w #$0020",
182
+ " bne -",
183
+ " lda #$00",
184
+ " pha",
185
+ " plb ; DBR = $00",
186
+ " ; DMA staging buffer → OAM",
187
+ " lda #$00",
188
+ " sta !OAMADDL",
189
+ " sta !OAMADDH",
190
+ " sta !DMAP0 ; 1-reg, increment",
191
+ " lda #$04",
192
+ " sta !BBAD0 ; B-bus = $2104 (OAMDATA)",
193
+ ` ldx #${stageHex}`,
194
+ " stx !A1T0L ; src addr low 16",
195
+ " lda #$7E",
196
+ " sta !A1B0 ; src bank",
197
+ " ldx #$0120 ; 288 bytes",
198
+ " stx !DAS0L",
199
+ " lda #$01",
200
+ " sta !MDMAEN ; fire DMA channel 0",
201
+ " plp",
202
+ " rts",
203
+ "",
204
+ "; ── seam: maintain the OAMDMA-page mirror; no-op the rest in phase 2 ──",
205
+ "NES_PPU_WRITE:",
206
+ " cpx #$14 ; $4014 (OAMDMA)?",
207
+ " bne +",
208
+ ` sta ${RT_RAM.OAMDMA_PAGE} ; A = the shadow-OAM source page`,
209
+ "+",
210
+ " rts",
211
+ "NES_PPU_READ:",
212
+ " lda #$80 ; PPUSTATUS vblank set → boot wait-loops exit",
213
+ " rts",
214
+ "NES_APU_WRITE:",
215
+ " rts",
216
+ "NES_OAM_DMA:",
217
+ " rts",
218
+ "",
219
+ ].join("\n");
220
+ }