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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +144 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/analysis/analyze.js +320 -48
- 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-65816.js +584 -0
- package/src/analysis/rizin.js +13 -1
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/capabilities.js +218 -0
- 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 +331 -24
- 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/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +9 -6
- package/src/mcp/tools/watch-memory.js +231 -11
- package/src/mcp/util.js +82 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/_worker/pool.js +41 -3
- 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,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
|
+
}
|