romdevtools 0.29.0 → 0.40.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 +14 -5
- package/CHANGELOG.md +114 -12
- package/README.md +2 -1
- package/examples/gb/templates/tile_engine.c +1 -1
- package/examples/gbc/templates/tile_engine.c +1 -1
- package/examples/genesis/templates/two_plane_parallax.c +4 -4
- package/examples/nes/templates/tile_engine.c +1 -1
- package/package.json +14 -12
- package/src/analysis/analyze.js +263 -0
- package/src/analysis/decompile.js +108 -0
- package/src/analysis/decompiler/sleigh/6502.cspec +34 -0
- package/src/analysis/decompiler/sleigh/6502.ldefs +33 -0
- package/src/analysis/decompiler/sleigh/6502.pspec +16 -0
- package/src/analysis/decompiler/sleigh/6502.sla +3414 -0
- package/src/analysis/decompiler/sleigh/65816-snes.pspec +249 -0
- package/src/analysis/decompiler/sleigh/65816.cspec +17 -0
- package/src/analysis/decompiler/sleigh/65816.ldefs +15 -0
- package/src/analysis/decompiler/sleigh/65816.sla +23670 -0
- package/src/analysis/decompiler/sleigh/65c02.sla +4683 -0
- package/src/analysis/decompiler/sleigh/68000.cspec +67 -0
- package/src/analysis/decompiler/sleigh/68000.ldefs +68 -0
- package/src/analysis/decompiler/sleigh/68000.pspec +9 -0
- package/src/analysis/decompiler/sleigh/68000_register.cspec +118 -0
- package/src/analysis/decompiler/sleigh/68040.sla +81693 -0
- package/src/analysis/decompiler/sleigh/ARM.ldefs +377 -0
- package/src/analysis/decompiler/sleigh/ARM4t_le.sla +53758 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.cspec +209 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.pspec +40 -0
- package/src/analysis/decompiler/sleigh/ARMt_v45.pspec +43 -0
- package/src/analysis/decompiler/sleigh/HuC6280.cspec +67 -0
- package/src/analysis/decompiler/sleigh/HuC6280.ldefs +18 -0
- package/src/analysis/decompiler/sleigh/HuC6280.pspec +150 -0
- package/src/analysis/decompiler/sleigh/HuC6280.sla +7524 -0
- package/src/analysis/decompiler/sleigh/sm83.cspec +70 -0
- package/src/analysis/decompiler/sleigh/sm83.ldefs +29 -0
- package/src/analysis/decompiler/sleigh/sm83.pspec +19 -0
- package/src/analysis/decompiler/sleigh/sm83.sla +7695 -0
- package/src/analysis/decompiler/sleigh/z80.cspec +122 -0
- package/src/analysis/decompiler/sleigh/z80.ldefs +57 -0
- package/src/analysis/decompiler/sleigh/z80.pspec +43 -0
- package/src/analysis/decompiler/sleigh/z80.sla +20518 -0
- package/src/analysis/decompiler/wasm/decompile.js +2 -0
- package/src/analysis/decompiler/wasm/decompile.wasm +0 -0
- package/src/analysis/rizin.js +129 -0
- package/src/analysis/wasm/rizin.js +6032 -0
- package/src/analysis/wasm/rizin.wasm +0 -0
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +25 -7
- package/src/http/routes.js +1 -1
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/cart-parts.js +5 -2
- package/src/mcp/tools/disasm.js +32 -5
- package/src/mcp/tools/font-map.js +3 -3
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/reinject.js +1 -1
- package/src/mcp/tools/run-until.js +8 -2
- package/src/mcp/tools/symbols.js +10 -4
- package/src/mcp/tools/trace-vram-source.js +1 -1
- package/src/mcp/tools/watch-memory.js +50 -8
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +80 -6
- package/src/platforms/atari2600/MENTAL_MODEL.md +6 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +6 -0
- package/src/platforms/c64/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/lib/c/README.md +1 -1
- package/src/platforms/gba/MENTAL_MODEL.md +7 -1
- package/src/platforms/gbc/MENTAL_MODEL.md +6 -0
- package/src/platforms/gbc/lib/c/README.md +1 -1
- package/src/platforms/genesis/MENTAL_MODEL.md +8 -2
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/genesis/lib/wram.s +1 -1
- package/src/platforms/gg/MENTAL_MODEL.md +6 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/nes/MENTAL_MODEL.md +6 -0
- package/src/platforms/pce/MENTAL_MODEL.md +6 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -0
- package/src/platforms/snes/MENTAL_MODEL.md +10 -4
- package/src/toolchains/_worker/wasm-worker.js +5 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// analyze.js — MCP-facing RE analysis ops built on the Rizin WASM engine:
|
|
2
|
+
// control-flow graphs, deep cross-references, auto-detected functions, and a
|
|
3
|
+
// one-shot structural map. Complements disasm.js (da65/native, rebuildable
|
|
4
|
+
// output) — rizin gives the GRAPH structure da65 can't.
|
|
5
|
+
//
|
|
6
|
+
// Address model: rizin's own bin-loader sets the load address for formats it
|
|
7
|
+
// recognizes (iNES → 0x8000, GBA → 0x08000000, raw → 0). For platforms whose
|
|
8
|
+
// flat file maps 1:1 to the CPU bus (Genesis, plain binaries) the file offset
|
|
9
|
+
// IS the CPU address. We pass an explicit base only where it helps; the
|
|
10
|
+
// reported addresses are rizin virtual addresses, which match the CPU view for
|
|
11
|
+
// the common (unbanked / first-bank) case. Banked carts: a bank's window is
|
|
12
|
+
// resolved by the existing disasm mappers — analysis here is whole-file.
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { runRizin, runRizinJson, RIZIN_ARCH } from "./rizin.js";
|
|
16
|
+
import { decompileFunction, SLEIGH_LANGID } from "./decompile.js";
|
|
17
|
+
|
|
18
|
+
/** Sniff platform from a ROM extension (mirrors disasm.js). */
|
|
19
|
+
export function sniffPlatform(p) {
|
|
20
|
+
if (/\.nes$/i.test(p)) return "nes";
|
|
21
|
+
if (/\.(sfc|smc)$/i.test(p)) return "snes";
|
|
22
|
+
if (/\.gbc$/i.test(p)) return "gbc";
|
|
23
|
+
if (/\.gb$/i.test(p)) return "gb";
|
|
24
|
+
if (/\.sms$/i.test(p)) return "sms";
|
|
25
|
+
if (/\.gg$/i.test(p)) return "gg";
|
|
26
|
+
if (/\.a26$/i.test(p)) return "atari2600";
|
|
27
|
+
if (/\.a78$/i.test(p)) return "atari7800";
|
|
28
|
+
if (/\.prg$/i.test(p)) return "c64";
|
|
29
|
+
if (/\.(lnx|lyx)$/i.test(p)) return "lynx";
|
|
30
|
+
if (/\.gba$/i.test(p)) return "gba";
|
|
31
|
+
if (/\.pce$/i.test(p)) return "pce";
|
|
32
|
+
if (/\.(gen|md|bin)$/i.test(p)) return "genesis";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** rizin asm.bits per arch (analysis defaults; rizin's loader usually sets
|
|
37
|
+
* these for recognized formats, but raw blobs need a hint). */
|
|
38
|
+
const BITS = { arm: 32, m68k: 32, snes: 16 };
|
|
39
|
+
|
|
40
|
+
/** Build the common rizin invocation context for a ROM + platform. Returns
|
|
41
|
+
* { romBytes, arch, bits, note } — arch null means let rizin sniff. */
|
|
42
|
+
async function loadContext(romPath, platformOverride) {
|
|
43
|
+
const platform = platformOverride ?? sniffPlatform(romPath);
|
|
44
|
+
if (!platform) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`analyze: could not determine platform from '${path.basename(romPath)}' — pass platform explicitly`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!(platform in RIZIN_ARCH)) {
|
|
50
|
+
throw new Error(`analyze: unsupported platform '${platform}'`);
|
|
51
|
+
}
|
|
52
|
+
const arch = RIZIN_ARCH[platform];
|
|
53
|
+
if (arch == null) {
|
|
54
|
+
throw new Error(`analyze: no Rizin arch mapping for platform '${platform}'`);
|
|
55
|
+
}
|
|
56
|
+
const romBytes = new Uint8Array(await readFile(romPath));
|
|
57
|
+
// PCE: rizin's 6502 plugin drives the loader + standard control flow for
|
|
58
|
+
// function detection, but mis-decodes HuC6280 custom opcodes — CFG/xrefs are
|
|
59
|
+
// approximate. Accurate HuC6280 decode is the decompiler's job (SLEIGH spec).
|
|
60
|
+
const approx = platform === "pce";
|
|
61
|
+
return { platform, romBytes, arch, bits: BITS[arch], approx };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Hex-format an address the way agents expect for the platform width. */
|
|
65
|
+
function hx(n) { return "0x" + (n >>> 0).toString(16); }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Auto-detected function list for a ROM.
|
|
69
|
+
* @returns {{platform, count, functions: Array<{address, name, size, nbbs, cc, callers, callees}>}}
|
|
70
|
+
*/
|
|
71
|
+
export async function analyzeFunctions(romPath, platformOverride) {
|
|
72
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
73
|
+
const fns = await runRizinJson({ romBytes, arch, bits, commands: "aaa; aflj" });
|
|
74
|
+
const functions = fns.map((f) => ({
|
|
75
|
+
address: f.offset,
|
|
76
|
+
addressHex: hx(f.offset),
|
|
77
|
+
name: f.name,
|
|
78
|
+
size: f.size,
|
|
79
|
+
nbbs: f.nbbs, // basic-block count
|
|
80
|
+
cc: f.cc, // cyclomatic complexity
|
|
81
|
+
callers: f.indegree ?? (f.codexrefs?.length ?? 0),
|
|
82
|
+
callees: f.outdegree ?? 0,
|
|
83
|
+
}));
|
|
84
|
+
return { platform, arch, count: functions.length, functions };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Control-flow graph for the function containing `address`.
|
|
89
|
+
* @returns {{platform, address, nodes: Array<{id,address,size,instructions,jump,fail,out}>, edges}}
|
|
90
|
+
*/
|
|
91
|
+
export async function analyzeCfg(romPath, address, platformOverride) {
|
|
92
|
+
if (address == null) throw new Error("analyze cfg: address required");
|
|
93
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
94
|
+
// afbj = basic blocks of the function as JSON: each block has addr/size/jump/
|
|
95
|
+
// fail/ninstr. `jump` is the taken edge; `fail` (present only on conditional
|
|
96
|
+
// blocks) is the fall-through. This is the structured CFG source — `agf json`
|
|
97
|
+
// only gives a text body blob with untyped out_nodes.
|
|
98
|
+
const blocks = await runRizinJson({
|
|
99
|
+
romBytes, arch, bits,
|
|
100
|
+
commands: `aaa; af @ ${hx(address)}; afbj @ ${hx(address)}`,
|
|
101
|
+
});
|
|
102
|
+
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
103
|
+
return { platform, arch, address, addressHex: hx(address), nodes: [], edges: [], note: "no function/blocks at address" };
|
|
104
|
+
}
|
|
105
|
+
const nodes = blocks.map((b) => ({
|
|
106
|
+
id: b.addr,
|
|
107
|
+
address: b.addr,
|
|
108
|
+
addressHex: hx(b.addr),
|
|
109
|
+
size: b.size,
|
|
110
|
+
ninstr: b.ninstr,
|
|
111
|
+
}));
|
|
112
|
+
const edges = [];
|
|
113
|
+
for (const b of blocks) {
|
|
114
|
+
const conditional = b.fail != null;
|
|
115
|
+
if (b.jump != null) edges.push({ from: b.addr, to: b.jump, type: conditional ? "branch_true" : "jump_or_fall" });
|
|
116
|
+
if (b.fail != null) edges.push({ from: b.addr, to: b.fail, type: "branch_false" });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
platform, arch,
|
|
120
|
+
address, addressHex: hx(address),
|
|
121
|
+
blockCount: nodes.length,
|
|
122
|
+
nodes, edges,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* All cross-references TO `address` across the ROM.
|
|
128
|
+
* @returns {{platform, address, count, refs: Array<{from, to, type}>}}
|
|
129
|
+
*/
|
|
130
|
+
export async function analyzeXrefs(romPath, address, platformOverride) {
|
|
131
|
+
if (address == null) throw new Error("analyze xrefs: address required");
|
|
132
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
133
|
+
let refs;
|
|
134
|
+
try {
|
|
135
|
+
refs = await runRizinJson({ romBytes, arch, bits, commands: `aaa; axtj @ ${hx(address)}` });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// axtj prints nothing (not even `[]`) when there are zero refs → our JSON
|
|
138
|
+
// guard throws. Treat "no JSON" as "no refs".
|
|
139
|
+
if (/no JSON/.test(e.message)) refs = [];
|
|
140
|
+
else throw e;
|
|
141
|
+
}
|
|
142
|
+
const out = (refs ?? []).map((r) => ({
|
|
143
|
+
from: r.from,
|
|
144
|
+
fromHex: hx(r.from),
|
|
145
|
+
to: r.to,
|
|
146
|
+
type: (r.type || "").toLowerCase(), // CALL / CODE / DATA / STRING
|
|
147
|
+
opcode: r.opcode,
|
|
148
|
+
}));
|
|
149
|
+
return { platform, arch, address, addressHex: hx(address), count: out.length, refs: out };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One-shot structural map: functions + strings + entrypoints from a full
|
|
154
|
+
* analysis pass. The "give me the shape of this ROM" call.
|
|
155
|
+
*/
|
|
156
|
+
export async function analyzeStructure(romPath, platformOverride) {
|
|
157
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
158
|
+
const [fns, strings, entries] = await Promise.all([
|
|
159
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; aflj" }).catch(() => []),
|
|
160
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; izj" }).catch(() => []),
|
|
161
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; iej" }).catch(() => []),
|
|
162
|
+
]);
|
|
163
|
+
return {
|
|
164
|
+
platform, arch,
|
|
165
|
+
functionCount: Array.isArray(fns) ? fns.length : 0,
|
|
166
|
+
stringCount: Array.isArray(strings) ? strings.length : 0,
|
|
167
|
+
entrypoints: (Array.isArray(entries) ? entries : []).map((e) => ({ address: e.vaddr, addressHex: hx(e.vaddr) })),
|
|
168
|
+
functions: (Array.isArray(fns) ? fns : []).slice(0, 512).map((f) => ({
|
|
169
|
+
address: f.offset, addressHex: hx(f.offset), name: f.name, size: f.size, callers: f.indegree ?? 0,
|
|
170
|
+
})),
|
|
171
|
+
strings: (Array.isArray(strings) ? strings : []).slice(0, 256).map((s) => ({
|
|
172
|
+
address: s.vaddr, addressHex: hx(s.vaddr), value: s.string,
|
|
173
|
+
})),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Look up rizin's IO map (omlj) for the segment containing `vaddr`. Each map
|
|
178
|
+
* entry carries {from (vaddr base), delta (vaddr-paddr), to}. Returns
|
|
179
|
+
* { paddr, vbase } where paddr = vaddr-delta is the raw-file offset and vbase
|
|
180
|
+
* (= delta) is the address byte 0 of the file maps to on the CPU bus. */
|
|
181
|
+
async function vaMapping(romBytes, arch, bits, vaddr) {
|
|
182
|
+
let maps;
|
|
183
|
+
try {
|
|
184
|
+
maps = await runRizinJson({ romBytes, arch, bits, commands: "omlj" });
|
|
185
|
+
} catch { maps = []; }
|
|
186
|
+
for (const m of (Array.isArray(maps) ? maps : [])) {
|
|
187
|
+
const from = m.from ?? m.vaddr ?? 0;
|
|
188
|
+
const to = m.to ?? (from + (m.size ?? 0));
|
|
189
|
+
if (vaddr >= from && vaddr < to) return { paddr: vaddr - (m.delta ?? 0), vbase: m.delta ?? 0 };
|
|
190
|
+
}
|
|
191
|
+
return { paddr: vaddr, vbase: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decompile the function containing `address` to C pseudocode (Ghidra).
|
|
196
|
+
* @returns {{platform, langid, address, code, warnings, qualityNote}}
|
|
197
|
+
*/
|
|
198
|
+
export async function analyzeDecompile(romPath, address, platformOverride) {
|
|
199
|
+
if (address == null) throw new Error("analyze decompile: address required");
|
|
200
|
+
const platform = platformOverride ?? sniffPlatform(romPath);
|
|
201
|
+
if (!platform) throw new Error(`analyze decompile: unknown platform for '${path.basename(romPath)}'`);
|
|
202
|
+
if (!SLEIGH_LANGID[platform]) throw new Error(`analyze decompile: unsupported platform '${platform}'`);
|
|
203
|
+
const romBytes = new Uint8Array(await readFile(romPath));
|
|
204
|
+
|
|
205
|
+
// Use rizin's loader mapping to turn the VA (what the user sees from
|
|
206
|
+
// target='functions') into the file offset the raw decompiler image needs.
|
|
207
|
+
// PCE uses the 6502 plugin only for the map/loader (HuC6280 decode is the
|
|
208
|
+
// decompiler's job via SLEIGH) — its flat image bases at 0 either way.
|
|
209
|
+
const arch = RIZIN_ARCH[platform] ?? "6502";
|
|
210
|
+
const bits = { arm: 32, m68k: 32, snes: 16 }[arch];
|
|
211
|
+
const { paddr, vbase } = await vaMapping(romBytes, arch, bits, address);
|
|
212
|
+
if (paddr < 0 || paddr >= romBytes.length) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`decompile: address ${hx(address)} maps to file offset ${paddr}, outside the ` +
|
|
215
|
+
`${romBytes.length}-byte image for ${platform}.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
// The raw decompiler loads byte 0 at VMA 0. Code that references absolute CPU
|
|
219
|
+
// addresses (typical 6502: JSR/JMP to $Fxxx) only resolves if the image sits
|
|
220
|
+
// at the right CPU base. Rizin's map gives `vbase` when it knows the base;
|
|
221
|
+
// some headerless carts (2600/7800) it loads at 0, so we supply the base from
|
|
222
|
+
// a per-platform table. Left-pad the image by the base so file offset == CPU
|
|
223
|
+
// address, then decompile at the function's CPU address. Capped at 64KB (the
|
|
224
|
+
// 6502 family's whole address space) so a large base never over-allocates.
|
|
225
|
+
// Atari 2600/7800 are headerless 6502 dumps rizin loads at 0; supply the real
|
|
226
|
+
// CPU base so absolute references ($8000/$C000/$F000) resolve. 7800's base is
|
|
227
|
+
// size-dependent (16KB→$C000, 32KB→$8000); 7800 carts may carry a 128-byte
|
|
228
|
+
// header before the body.
|
|
229
|
+
let forcedBase = 0, bodyStart = 0;
|
|
230
|
+
if (platform === "atari2600") {
|
|
231
|
+
forcedBase = 0xf000;
|
|
232
|
+
} else if (platform === "atari7800") {
|
|
233
|
+
const hasHdr = romBytes.length > 128 &&
|
|
234
|
+
romBytes[1] === 0x41 && romBytes[2] === 0x54; // "AT"
|
|
235
|
+
bodyStart = hasHdr ? 128 : 0;
|
|
236
|
+
const body = romBytes.length - bodyStart;
|
|
237
|
+
forcedBase = body <= 0x4000 ? 0xc000 : body <= 0x8000 ? 0x8000 : 0x4000;
|
|
238
|
+
}
|
|
239
|
+
const base = vbase > 0 ? vbase : forcedBase;
|
|
240
|
+
let image = romBytes, decompAddr = paddr;
|
|
241
|
+
if (base > 0 && base <= 0x10000) {
|
|
242
|
+
const body = romBytes.subarray(bodyStart);
|
|
243
|
+
const padded = new Uint8Array(base + body.length);
|
|
244
|
+
padded.set(body, base);
|
|
245
|
+
image = padded;
|
|
246
|
+
decompAddr = base + (paddr - bodyStart); // CPU address of the function
|
|
247
|
+
}
|
|
248
|
+
const r = await decompileFunction({ platform, romBytes: image, fileOffset: decompAddr });
|
|
249
|
+
const QUALITY = {
|
|
250
|
+
gba: "excellent (ARM)", genesis: "excellent (M68K)",
|
|
251
|
+
gb: "good (SM83)", gbc: "good (SM83)", sms: "good (Z80)", gg: "good (Z80)", msx: "good (Z80)",
|
|
252
|
+
snes: "medium (65816 variable register width)", pce: "medium (HuC6280)",
|
|
253
|
+
nes: "rough (6502 architecture limit)", atari2600: "rough (6502)", atari7800: "rough (6502)",
|
|
254
|
+
c64: "rough (6502)", lynx: "rough (65C02)",
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
platform, langid: r.langid,
|
|
258
|
+
address, addressHex: hx(address),
|
|
259
|
+
code: r.code,
|
|
260
|
+
warnings: r.warnings,
|
|
261
|
+
qualityNote: QUALITY[platform] ?? "unknown",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// decompile.js — drive the Ghidra decompiler WASM (romdev-analysis-decompiler)
|
|
2
|
+
// to turn a function into C pseudocode. Runs the decompiler's REPL one-shot
|
|
3
|
+
// through the isolated worker pool: mount the SLEIGH home + the ROM image,
|
|
4
|
+
// feed `load file <langid> <rom>; map function <addr>; decompile; print C`.
|
|
5
|
+
//
|
|
6
|
+
// The ROM is loaded as a RAW binary at VMA 0 — so the address passed must be a
|
|
7
|
+
// FILE OFFSET into the image we hand it, not a banked CPU address. Callers that
|
|
8
|
+
// have a CPU address use the disasm mappers to slice the right bank first; for
|
|
9
|
+
// the common flat/first-bank case the file offset equals the CPU address minus
|
|
10
|
+
// the platform's load base (handled in analyze.js).
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { runIsolated } from "../toolchains/_worker/run.js";
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/** Resolve the decompiler package (or the gitignored src staging fallback). */
|
|
19
|
+
function decompilerPaths() {
|
|
20
|
+
let base;
|
|
21
|
+
try {
|
|
22
|
+
base = path.dirname(fileURLToPath(import.meta.resolve("romdev-analysis-decompiler")));
|
|
23
|
+
} catch { base = null; }
|
|
24
|
+
const candidates = [
|
|
25
|
+
base && { js: path.join(base, "wasm", "decompile.js"), sleigh: path.join(base, "sleigh") },
|
|
26
|
+
{ js: path.join(__dirname, "decompiler", "wasm", "decompile.js"), sleigh: path.join(__dirname, "decompiler", "sleigh") },
|
|
27
|
+
].filter(Boolean);
|
|
28
|
+
for (const c of candidates) if (fs.existsSync(c.js)) return c;
|
|
29
|
+
throw new Error(
|
|
30
|
+
"decompiler not found: install romdev-analysis-decompiler or run scripts/build-decompiler.sh"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** romdev platform → Ghidra SLEIGH language id. null = not decompilable yet. */
|
|
35
|
+
export const SLEIGH_LANGID = {
|
|
36
|
+
nes: "6502:LE:16:default",
|
|
37
|
+
atari2600: "6502:LE:16:default",
|
|
38
|
+
atari7800: "6502:LE:16:default",
|
|
39
|
+
c64: "6502:LE:16:default",
|
|
40
|
+
lynx: "65C02:LE:16:default",
|
|
41
|
+
sms: "z80:LE:16:default",
|
|
42
|
+
gg: "z80:LE:16:default",
|
|
43
|
+
msx: "z80:LE:16:default",
|
|
44
|
+
gb: "SM83:LE:16:default",
|
|
45
|
+
gbc: "SM83:LE:16:default",
|
|
46
|
+
gba: "ARM:LE:32:v4t",
|
|
47
|
+
genesis: "68000:BE:32:default",
|
|
48
|
+
snes: "65816:LE:24:snes",
|
|
49
|
+
pce: "HuC6280:LE:16:default",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decompile the function at `fileOffset` in `romBytes` for `platform`.
|
|
54
|
+
* @returns {{platform, langid, address, code, warnings:string[], raw:string}}
|
|
55
|
+
*/
|
|
56
|
+
export async function decompileFunction({ platform, romBytes, fileOffset, name = "fn_target" }) {
|
|
57
|
+
const langid = SLEIGH_LANGID[platform];
|
|
58
|
+
if (!langid) throw new Error(`decompile: no SLEIGH language for platform '${platform}'`);
|
|
59
|
+
const { js, sleigh } = decompilerPaths();
|
|
60
|
+
const addr = "0x" + (fileOffset >>> 0).toString(16);
|
|
61
|
+
|
|
62
|
+
// REPL script. RawBinary loads at vma 0; `print C` emits the pseudocode.
|
|
63
|
+
const script = [
|
|
64
|
+
`load file ${langid} /work/rom.bin`,
|
|
65
|
+
`map function ${addr} ${name}`,
|
|
66
|
+
`decompile ${name}`,
|
|
67
|
+
`print C`,
|
|
68
|
+
`quit`,
|
|
69
|
+
"",
|
|
70
|
+
].join("\n");
|
|
71
|
+
|
|
72
|
+
const res = await runIsolated({
|
|
73
|
+
gluePath: js,
|
|
74
|
+
argv: [],
|
|
75
|
+
env: { SLEIGHHOME: "/sleigh" },
|
|
76
|
+
stdinText: script,
|
|
77
|
+
hostDirMounts: [{ hostDir: sleigh, vfsDir: "/sleigh" }],
|
|
78
|
+
inputFiles: [{
|
|
79
|
+
vfsPath: "/work/rom.bin",
|
|
80
|
+
encoding: "base64",
|
|
81
|
+
data: Buffer.from(romBytes).toString("base64"),
|
|
82
|
+
}],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const raw = res.log ?? "";
|
|
86
|
+
// The decompiler echoes each prompt; the C body follows `print C`.
|
|
87
|
+
const code = extractC(raw);
|
|
88
|
+
const warnings = [...raw.matchAll(/\/\* WARNING: (.+?) \*\//g)].map((m) => m[1]);
|
|
89
|
+
if (!code) {
|
|
90
|
+
throw new Error(`decompile produced no C output (exit=${res.exitCode}): ${raw.slice(-400)}`);
|
|
91
|
+
}
|
|
92
|
+
return { platform, langid, address: fileOffset, addressHex: addr, code, warnings, raw };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Pull the C function text out of the REPL transcript (between `print C` and
|
|
96
|
+
* the next `[decomp]>` prompt). */
|
|
97
|
+
function extractC(transcript) {
|
|
98
|
+
const lines = transcript.split("\n");
|
|
99
|
+
const start = lines.findIndex((l) => /\[decomp\]>\s*print C\b/.test(l));
|
|
100
|
+
if (start === -1) return null;
|
|
101
|
+
const body = [];
|
|
102
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
103
|
+
if (/^\[decomp\]>/.test(lines[i])) break;
|
|
104
|
+
body.push(lines[i]);
|
|
105
|
+
}
|
|
106
|
+
const text = body.join("\n").trim();
|
|
107
|
+
return text.length ? text : null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
|
|
3
|
+
<compiler_spec>
|
|
4
|
+
<global>
|
|
5
|
+
<range space="RAM"/>
|
|
6
|
+
</global>
|
|
7
|
+
<stackpointer register="SP" space="RAM" growth="negative"/>
|
|
8
|
+
<returnaddress>
|
|
9
|
+
<varnode space="stack" offset="1" size="2"/>
|
|
10
|
+
</returnaddress>
|
|
11
|
+
<default_proto>
|
|
12
|
+
<prototype name="__stdcall" extrapop="2" stackshift="2" strategy="register">
|
|
13
|
+
<input>
|
|
14
|
+
<pentry minsize="1" maxsize="1">
|
|
15
|
+
<register name="A"/>
|
|
16
|
+
</pentry>
|
|
17
|
+
<pentry minsize="1" maxsize="1">
|
|
18
|
+
<register name="X"/>
|
|
19
|
+
</pentry>
|
|
20
|
+
<pentry minsize="1" maxsize="1">
|
|
21
|
+
<register name="Y"/>
|
|
22
|
+
</pentry>
|
|
23
|
+
</input>
|
|
24
|
+
<output>
|
|
25
|
+
<pentry minsize="1" maxsize="1">
|
|
26
|
+
<register name="A"/>
|
|
27
|
+
</pentry>
|
|
28
|
+
</output>
|
|
29
|
+
<unaffected>
|
|
30
|
+
<register name="SP"/>
|
|
31
|
+
</unaffected>
|
|
32
|
+
</prototype>
|
|
33
|
+
</default_proto>
|
|
34
|
+
</compiler_spec>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
|
|
3
|
+
<language_definitions>
|
|
4
|
+
|
|
5
|
+
<language processor="6502"
|
|
6
|
+
endian="little"
|
|
7
|
+
size="16"
|
|
8
|
+
variant="default"
|
|
9
|
+
version="1.0"
|
|
10
|
+
slafile="6502.sla"
|
|
11
|
+
processorspec="6502.pspec"
|
|
12
|
+
manualindexfile="../manuals/6502.idx"
|
|
13
|
+
id="6502:LE:16:default">
|
|
14
|
+
<description>6502 Microcontroller Family</description>
|
|
15
|
+
<compiler name="default" spec="6502.cspec" id="default"/>
|
|
16
|
+
<external_name tool="IDA-PRO" name="m6502"/>
|
|
17
|
+
</language>
|
|
18
|
+
|
|
19
|
+
<language processor="65C02"
|
|
20
|
+
endian="little"
|
|
21
|
+
size="16"
|
|
22
|
+
variant="default"
|
|
23
|
+
version="1.0"
|
|
24
|
+
slafile="65c02.sla"
|
|
25
|
+
processorspec="6502.pspec"
|
|
26
|
+
manualindexfile="../manuals/65c02.idx"
|
|
27
|
+
id="65C02:LE:16:default">
|
|
28
|
+
<description>65C02 Microcontroller Family</description>
|
|
29
|
+
<compiler name="default" spec="6502.cspec" id="default"/>
|
|
30
|
+
<external_name tool="IDA-PRO" name="m65c02"/>
|
|
31
|
+
</language>
|
|
32
|
+
|
|
33
|
+
</language_definitions>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
|
|
3
|
+
<processor_spec>
|
|
4
|
+
<programcounter register="PC"/>
|
|
5
|
+
|
|
6
|
+
<default_symbols>
|
|
7
|
+
<symbol name="NMI" address="FFFA" entry="true" type="code_ptr"/>
|
|
8
|
+
<symbol name="RES" address="FFFC" entry="true" type="code_ptr"/>
|
|
9
|
+
<symbol name="IRQ" address="FFFE" entry="true" type="code_ptr"/>
|
|
10
|
+
</default_symbols>
|
|
11
|
+
|
|
12
|
+
<default_memory_blocks>
|
|
13
|
+
<memory_block name="ZERO_PAGE" start_address="0x0000" length="0x0100" initialized="false"/>
|
|
14
|
+
<memory_block name="STACK" start_address="0x0100" length="0x0100" initialized="false"/>
|
|
15
|
+
</default_memory_blocks>
|
|
16
|
+
</processor_spec>
|