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