romdevtools 0.40.2 → 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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +1 -1
  4. package/package.json +3 -1
  5. package/src/analysis/analyze.js +320 -48
  6. package/src/analysis/backtrace.js +198 -0
  7. package/src/analysis/nes-ppu-runtime.js +220 -0
  8. package/src/analysis/nes-ppu-shim.js +263 -0
  9. package/src/analysis/pointer-table.js +84 -0
  10. package/src/analysis/recompile-65816.js +584 -0
  11. package/src/analysis/rizin.js +13 -1
  12. package/src/cheats/gamegenie.js +14 -2
  13. package/src/cores/capabilities.js +218 -0
  14. package/src/cores/registry.js +43 -51
  15. package/src/mcp/state.js +91 -2
  16. package/src/mcp/tools/cheats.js +10 -1
  17. package/src/mcp/tools/disasm.js +331 -24
  18. package/src/mcp/tools/find-references.js +93 -2
  19. package/src/mcp/tools/frame.js +440 -4
  20. package/src/mcp/tools/index.js +34 -1
  21. package/src/mcp/tools/lifecycle.js +53 -24
  22. package/src/mcp/tools/memory.js +1 -1
  23. package/src/mcp/tools/platform-tools.js +17 -5
  24. package/src/mcp/tools/platforms.js +18 -3
  25. package/src/mcp/tools/playtest.js +24 -1
  26. package/src/mcp/tools/project.js +1 -1
  27. package/src/mcp/tools/rendering-context.js +9 -6
  28. package/src/mcp/tools/watch-memory.js +231 -11
  29. package/src/mcp/util.js +82 -0
  30. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
  31. package/src/playtest/playtest.js +115 -46
  32. package/src/toolchains/_worker/pool.js +41 -3
  33. package/src/toolchains/cc65/da65.js +7 -0
  34. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  35. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  36. package/src/cores/wasm/fceumm_libretro.js +0 -2
  37. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  38. package/src/cores/wasm/gambatte_libretro.js +0 -2
  39. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  40. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  41. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  42. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  43. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  44. package/src/cores/wasm/handy_libretro.js +0 -2
  45. package/src/cores/wasm/handy_libretro.wasm +0 -0
  46. package/src/cores/wasm/mgba_libretro.js +0 -2
  47. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  48. package/src/cores/wasm/prosystem_libretro.js +0 -2
  49. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  50. package/src/cores/wasm/snes9x_libretro.js +0 -2
  51. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  52. package/src/cores/wasm/stella2014_libretro.js +0 -2
  53. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  54. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  55. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -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
+ }
@@ -0,0 +1,263 @@
1
+ // NES-PPU-on-SNES runtime shim — the piece that fills the STUBBED seam left by
2
+ // the NES→SNES recompile backend (recompile-65816.js). Without it, a recompiled
3
+ // port boots and runs the 6502 logic but renders BLANK, because every NES PPU
4
+ // write the logic makes is trapped to rts. This shim makes the boot picture
5
+ // actually appear on SNES hardware.
6
+ //
7
+ // STATUS: WORKING (phase-1 static boot picture). The CONVERSION half
8
+ // (nesTileToSnes4bpp / nesColorToBgr555 / buildSnesAssets) is correct and
9
+ // unit-tested, and the emitted 65816 UPLOAD routine (emitPpuShim →
10
+ // NES_SHIM_PRESENT) now DMAs tiles+tilemap+palette to SNES VRAM/CGRAM and turns
11
+ // the screen on — verified end-to-end on snes9x (test/recompile-shim-render.test.js
12
+ // asserts the converted assets land in VRAM/CGRAM; a recompiled NES boot screen
13
+ // renders in color). The recompile op still gates the shim behind withShim:true
14
+ // because phase 1 only draws the STATIC first screen — the recompiled NES logic
15
+ // then runs in emulation mode against a STUBBED PPU seam, so animation/scroll/
16
+ // sprites are not maintained yet (next layer; see the design note below).
17
+ //
18
+ // The bug that made this "experimental" for a while: `rep #$10` makes X 16-bit at
19
+ // runtime, but asar sizes index immediates by the literal and assembled a bare
20
+ // `cpx #32` (the small CGRAM count) as an 8-bit instruction — the CPU then decoded
21
+ // 3 bytes, ate the next opcode, and the whole routine derailed (blank screen +
22
+ // CPU runaway). Fix: `cpx.w` on every loop. The compareRender/findDiverge oracles
23
+ // were the tools that localized it.
24
+ //
25
+ // DESIGN (phase 1 — the STATIC boot picture). Rather than hand-write a full NES
26
+ // PPU emulator in 65816 asm, the shim is generated at recompile time from the
27
+ // LIVE NES PPU state: the recompiler already boots the original ROM, so we read
28
+ // its CHR (tiles), nametable (the background map), and palette AFTER boot,
29
+ // CONVERT them to SNES formats in JS, and emit:
30
+ // - SNES tile data (NES 2bpp → SNES 4bpp), as bytes to incbin/db
31
+ // - a SNES tilemap (from the NES nametable), and
32
+ // - a SNES palette (NES indices → BGR555 CGRAM)
33
+ // - a small fixed 65816 routine that DMAs all three to VRAM/CGRAM and turns
34
+ // the screen on.
35
+ // The result: the port shows the same first screen as the original. This is the
36
+ // static path; animated/scrolling/sprite presentation is the next layer (the
37
+ // shim would then run per-frame off the seam's maintained NES-VRAM mirror).
38
+ //
39
+ // Plain JS ESM + JSDoc. Pairs with recompile-65816.js + the NES PPU read/decode
40
+ // helpers in platforms/nes/ppu.js.
41
+
42
+ import { decodeTile, nesPaletteIndexToRgb } from "../platforms/nes/ppu.js";
43
+
44
+ /**
45
+ * Convert one NES 2bpp tile (16 bytes) to one SNES 4bpp tile (32 bytes).
46
+ *
47
+ * NES tile: 8 bytes plane0 then 8 bytes plane1 (bit per pixel each), giving a
48
+ * 2-bit (0-3) index per pixel. SNES 4bpp tile: 32 bytes as TWO bitplane pairs —
49
+ * bytes 0-15 are planes 0&1 row-interleaved (lo,hi,lo,hi,... per row), bytes
50
+ * 16-31 are planes 2&3 the same way. We map the NES 2-bit index straight into
51
+ * the low two SNES planes; planes 2&3 stay 0 (NES only has 4 colors per tile).
52
+ *
53
+ * @param {Uint8Array} nesTile16
54
+ * @returns {Uint8Array} 32-byte SNES 4bpp tile
55
+ */
56
+ export function nesTileToSnes4bpp(nesTile16) {
57
+ const px = decodeTile(nesTile16); // 64 entries, each 0-3
58
+ const out = new Uint8Array(32);
59
+ for (let y = 0; y < 8; y++) {
60
+ let p0 = 0, p1 = 0;
61
+ for (let x = 0; x < 8; x++) {
62
+ const v = px[y * 8 + x];
63
+ const bit = 7 - x;
64
+ if (v & 1) p0 |= 1 << bit;
65
+ if (v & 2) p1 |= 1 << bit;
66
+ }
67
+ // planes 0&1 row-interleaved in the first 16 bytes
68
+ out[y * 2] = p0;
69
+ out[y * 2 + 1] = p1;
70
+ // planes 2&3 (bytes 16-31) remain 0
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Convert a NES palette byte (a 6-bit NES color index) to a SNES BGR555 uint16.
77
+ * NES index → RGB888 (via the canonical NES palette) → BGR555 (5 bits each,
78
+ * blue high). This is the CGRAM word format.
79
+ * @param {number} nesIndex
80
+ * @returns {number} 0..0x7FFF
81
+ */
82
+ export function nesColorToBgr555(nesIndex) {
83
+ const [r, g, b] = nesPaletteIndexToRgb(nesIndex & 0x3f);
84
+ const r5 = (r >> 3) & 0x1f;
85
+ const g5 = (g >> 3) & 0x1f;
86
+ const b5 = (b >> 3) & 0x1f;
87
+ return (b5 << 10) | (g5 << 5) | r5;
88
+ }
89
+
90
+ /**
91
+ * Build the SNES presentation assets from live NES PPU state.
92
+ *
93
+ * @param {Object} state
94
+ * @param {Uint8Array} state.chr NES pattern data (>=4096 bytes; the BG
95
+ * pattern table — we take the first 256 tiles / 4KB).
96
+ * @param {Uint8Array} state.nametable one 32x30 NES nametable's tile indices
97
+ * (960 bytes; the attribute bytes that follow are ignored in phase 1).
98
+ * @param {Uint8Array} state.palette 32-byte NES palette ($3F00-$3F1F). We use
99
+ * the BG palette (first 16 bytes) for CGRAM.
100
+ * @returns {{ tiles: Uint8Array, tilemap: Uint8Array, cgram: Uint8Array,
101
+ * tileCount: number, mapEntries: number }}
102
+ */
103
+ export function buildSnesAssets({ chr, nametable, palette }) {
104
+ // Tiles: first 256 NES tiles (4KB) → 256 SNES 4bpp tiles (8KB).
105
+ const tileCount = Math.min(256, Math.floor(chr.length / 16));
106
+ const tiles = new Uint8Array(tileCount * 32);
107
+ for (let t = 0; t < tileCount; t++) {
108
+ const nes = chr.subarray(t * 16, t * 16 + 16);
109
+ tiles.set(nesTileToSnes4bpp(nes), t * 32);
110
+ }
111
+
112
+ // Tilemap: NES nametable is 32x30 of 1-byte tile indices. SNES BG map entries
113
+ // are 16-bit (tile# in low 10 bits + palette/priority/flip in the high bits).
114
+ // Phase 1: palette 0, no flip/priority → entry = tile index. SNES BG map is
115
+ // 32x32; we fill the first 30 rows and leave the bottom 2 as tile 0.
116
+ const mapEntries = 32 * 32;
117
+ const tilemap = new Uint8Array(mapEntries * 2);
118
+ for (let row = 0; row < 30; row++) {
119
+ for (let col = 0; col < 32; col++) {
120
+ const nesTile = nametable[row * 32 + col] ?? 0;
121
+ const e = (row * 32 + col) * 2;
122
+ tilemap[e] = nesTile; // low byte = tile index
123
+ tilemap[e + 1] = 0; // high byte = palette 0, no flip/prio
124
+ }
125
+ }
126
+
127
+ // CGRAM: NES BG palette (16 bytes) → 16 BGR555 colors. NES color 0 ($3F00) is
128
+ // the universal backdrop → CGRAM index 0.
129
+ const cgram = new Uint8Array(16 * 2);
130
+ for (let i = 0; i < 16; i++) {
131
+ const bgr = nesColorToBgr555(palette[i] ?? 0);
132
+ cgram[i * 2] = bgr & 0xff;
133
+ cgram[i * 2 + 1] = (bgr >> 8) & 0xff;
134
+ }
135
+
136
+ return { tiles, tilemap, cgram, tileCount, mapEntries };
137
+ }
138
+
139
+ /** Format a byte array as asar `db $xx,$xx,...` lines (16 per line). */
140
+ function dbLines(bytes, indent = " ") {
141
+ const lines = [];
142
+ for (let i = 0; i < bytes.length; i += 16) {
143
+ const chunk = Array.from(bytes.subarray(i, i + 16))
144
+ .map((b) => "$" + b.toString(16).padStart(2, "0"))
145
+ .join(",");
146
+ lines.push(`${indent}db ${chunk}`);
147
+ }
148
+ return lines.join("\n");
149
+ }
150
+
151
+ /**
152
+ * Emit the SNES PPU shim as asar source: the converted data tables + a fixed
153
+ * 65816 routine `NES_SHIM_PRESENT` that DMAs tiles→VRAM, tilemap→VRAM,
154
+ * palette→CGRAM, sets BG mode 0 / bases, and turns the screen on. The
155
+ * recompiled reset wrapper calls NES_SHIM_PRESENT once after the game's boot
156
+ * logic has run (so the NES state is fully set up before we snapshot+convert).
157
+ *
158
+ * VRAM layout (word addresses): tiles at $0000, BG1 tilemap at $4000.
159
+ *
160
+ * @param {ReturnType<typeof buildSnesAssets>} assets
161
+ * @returns {string} asar source (define NES_SHIM_PRESENT + the data)
162
+ */
163
+ export function emitPpuShim(assets) {
164
+ const { tiles, tilemap, cgram, tileCount } = assets;
165
+ return [
166
+ "; ── NES-PPU-on-SNES shim (phase 1: static boot picture) ──────────────",
167
+ "; Converted from the original ROM's live PPU state at recompile time:",
168
+ `; ${tileCount} tiles (NES 2bpp → SNES 4bpp), a 32x30 BG tilemap, 16 colors.`,
169
+ "; NES_SHIM_PRESENT DMAs them to VRAM/CGRAM and enables BG1. Call it once",
170
+ "; after the game's boot logic has set up its PPU state.",
171
+ "",
172
+ "; SNES PPU registers used by the shim",
173
+ "!INIDISP = $2100",
174
+ "!BGMODE = $2105",
175
+ "!BG1SC = $2107 ; BG1 map base + size",
176
+ "!BG12NBA = $210B ; BG1/BG2 char base",
177
+ "!VMAIN = $2115",
178
+ "!VMADDL = $2116",
179
+ "!VMDATAL = $2118",
180
+ "!CGADD = $2121",
181
+ "!CGDATA = $2122",
182
+ "!TM = $212C",
183
+ "",
184
+ "NES_SHIM_PRESENT:",
185
+ " php",
186
+ " sep #$20 ; 8-bit A for register writes",
187
+ " rep #$10 ; 16-bit X/Y for loop indices",
188
+ "",
189
+ " lda #$80",
190
+ " sta !INIDISP ; forced blank during upload",
191
+ " lda #$80",
192
+ " sta !VMAIN ; VRAM addr +1 word after the HIGH-byte write",
193
+ "",
194
+ " ; ── tiles → VRAM word $0000 ── (word writes via VMDATAL/H)",
195
+ " ldx #$0000",
196
+ " stx !VMADDL ; VRAM word address $0000 (16-bit store)",
197
+ " ldx #$0000",
198
+ "- lda.l NES_SHIM_TILES,x",
199
+ " sta !VMDATAL ; low byte",
200
+ " inx",
201
+ " lda.l NES_SHIM_TILES,x",
202
+ " sta !VMDATAL+1 ; high byte → triggers +1 word",
203
+ " inx",
204
+ // `cpx.w` forces the 16-bit immediate form. We ran `rep #$10`, so X is
205
+ // 16-bit at RUNTIME, but asar does NOT track register width across rep/sep —
206
+ // it sizes index immediates by the literal, defaulting <256 to 8-bit. A bare
207
+ // `cpx #32` would assemble to 2 bytes while the CPU decodes 3, eating the
208
+ // next opcode and derailing the routine. The big tile/map counts (>255)
209
+ // happen to force 16-bit anyway; the small CGRAM count (32) is the one that
210
+ // bit us — so make ALL three explicit with `.w` and never rely on the value.
211
+ ` cpx.w #${tiles.length}`,
212
+ " bne -",
213
+ "",
214
+ " ; ── tilemap → VRAM word $4000 ──",
215
+ " ldx #$4000",
216
+ " stx !VMADDL ; VRAM word address $4000",
217
+ " ldx #$0000",
218
+ "- lda.l NES_SHIM_MAP,x",
219
+ " sta !VMDATAL",
220
+ " inx",
221
+ " lda.l NES_SHIM_MAP,x",
222
+ " sta !VMDATAL+1",
223
+ " inx",
224
+ ` cpx.w #${tilemap.length}`,
225
+ " bne -",
226
+ "",
227
+ " ; ── palette → CGRAM index 0 ──",
228
+ " lda #$00",
229
+ " sta !CGADD",
230
+ " ldx #$0000",
231
+ `- lda.l NES_SHIM_CGRAM,x`,
232
+ " sta !CGDATA ; CGADD auto-increments after each byte",
233
+ " inx",
234
+ ` cpx.w #${cgram.length} ; .w: 16-bit X at runtime (see note above)`,
235
+ " bne -",
236
+ "",
237
+ " ; ── BG setup + screen on ──",
238
+ " lda #$00",
239
+ " sta !BGMODE ; mode 0, 8x8 tiles",
240
+ " lda #$40",
241
+ " sta !BG1SC ; BG1 map base = VRAM word $4000 ((reg>>2)<<10)",
242
+ " lda #$00",
243
+ " sta !BG12NBA ; BG1 char base = VRAM word $0000",
244
+ " lda #$01",
245
+ " sta !TM ; enable BG1 on main screen",
246
+ " lda #$0f",
247
+ " sta !INIDISP ; full brightness, blank off",
248
+ " plp",
249
+ " rts",
250
+ "",
251
+ "; Shim data in its OWN bank so `lda.l TABLE,x` indexing never straddles a",
252
+ "; LoROM 32KB bank boundary (the 8KB tile table would otherwise wrap). Bank 2",
253
+ "; ($02:8000) is free after the code (bank 0) and the recompiled body.",
254
+ "org $028000",
255
+ "NES_SHIM_TILES:",
256
+ dbLines(tiles),
257
+ "NES_SHIM_MAP:",
258
+ dbLines(tilemap),
259
+ "NES_SHIM_CGRAM:",
260
+ dbLines(cgram),
261
+ "",
262
+ ].join("\n");
263
+ }