romdevtools 0.40.0 → 0.40.2
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/CHANGELOG.md +42 -0
- package/README.md +9 -1
- package/package.json +1 -1
- package/src/analysis/analyze.js +102 -2
- package/src/http/skill-doc.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to `romdevtools`. Dates are release dates.
|
|
|
4
4
|
(Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
|
|
5
5
|
the `romdev-mcp` bin is kept as an alias.)
|
|
6
6
|
|
|
7
|
+
## 0.40.2 — 2026-06-11
|
|
8
|
+
|
|
9
|
+
### Fixed — SNES `disasm({target:'decompile'})` treated the address as a raw file offset
|
|
10
|
+
|
|
11
|
+
On SNES, `decompile` decompiled the function at raw FILE offset `address`, but
|
|
12
|
+
`functions` / `cfg` / `xrefs` all report LoROM/HiROM **CPU** addresses — so the
|
|
13
|
+
documented "decompile an address from `functions`" loop silently returned the
|
|
14
|
+
wrong function (e.g. asking for the entry at CPU `$00:8000` decompiled file
|
|
15
|
+
`0x8000` = CPU `$01:8000`, the wrong bank).
|
|
16
|
+
|
|
17
|
+
Fix: SNES now lays the cart out by **24-bit CPU address** — each ROM chunk is
|
|
18
|
+
placed at its CPU bank (LoROM `$bank:8000`, HiROM `$C0+bank`), with the
|
|
19
|
+
`$80-$FF` FastROM (and HiROM `$40-$7F`) mirrors filled in — then decompiles at
|
|
20
|
+
the CPU address directly. This fixes both the function lookup AND in-bank /
|
|
21
|
+
`jsl` operand resolution (a flat-at-0 image would mis-label every cross-bank
|
|
22
|
+
call). The CPU-addressed image is ~16MB sparse (zero-filled between banks); a
|
|
23
|
+
real 4MB cart decompiles in ~1.2s. (No change to the `romdev-analysis*`
|
|
24
|
+
packages — the fix is in the address-mapping JS.)
|
|
25
|
+
|
|
26
|
+
Note: 65816 decompiler output is medium quality (variable register width, BCD/
|
|
27
|
+
decimal-flag expansion, direct-page guards) — for SNES, lean on `cfg` / `xrefs`
|
|
28
|
+
+ targeted `decompile` of leaf routines over big dispatchers.
|
|
29
|
+
|
|
30
|
+
## 0.40.1 — 2026-06-11
|
|
31
|
+
|
|
32
|
+
### Fixed — Genesis `disasm({target:'decompile'})` was shifted +0x200
|
|
33
|
+
|
|
34
|
+
A Genesis decompile at a caller-supplied address silently returned the function
|
|
35
|
+
0x200 bytes too low (the wrong one, or an empty `{ return; }` / `halt_baddata()`),
|
|
36
|
+
with no warning. `cfg` / `xrefs` / `functions` on the same address were correct —
|
|
37
|
+
only `decompile` was off.
|
|
38
|
+
|
|
39
|
+
Root cause: Rizin's Mega Drive loader splits a flat `.bin` into vtable / header /
|
|
40
|
+
text segments and reports a non-zero address delta (`0x200`) on the code segment;
|
|
41
|
+
the decompiler's address mapping honored that delta, but the raw image handed to
|
|
42
|
+
Ghidra loads flat at offset 0, so the two disagreed by exactly 0x200. Fix: flat-
|
|
43
|
+
cartridge platforms (Genesis, SMS, Game Gear, MSX, Game Boy / GBC) now force
|
|
44
|
+
file-offset == CPU-address and ignore Rizin's segment delta. The 6502-family
|
|
45
|
+
platforms were unaffected (they use a separate base-address path). Regression
|
|
46
|
+
test added. (No change to the `romdev-analysis` / `romdev-analysis-decompiler`
|
|
47
|
+
packages — the fix is entirely in the address-mapping JS.)
|
|
48
|
+
|
|
7
49
|
## 0.40.0 — 2026-06-11
|
|
8
50
|
|
|
9
51
|
### Reverse-engineering analysis engine — control-flow graphs, deep xrefs, function detection, and a decompiler
|
package/README.md
CHANGED
|
@@ -6,7 +6,15 @@ The entry point for **romdev** — vibe-code real retro games. Build, run, inspe
|
|
|
6
6
|
npx romdevtools
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**What you get:**
|
|
10
|
+
|
|
11
|
+
- **Build** — bundled per-platform toolchains (cc65, SDCC, RGBDS, asar, vasm, SGDK, PVSnesLib, libtonc, …) as WASM. Write source, compile, get a real ROM.
|
|
12
|
+
- **Run + see + drive** — load the ROM into an emulated console (libretro cores as WASM), step frames, screenshot, script controller input.
|
|
13
|
+
- **Inspect + romhack** — read CPU/video/save RAM, watch memory, write-breakpoints, the Cheat-Engine value-search loop, a bundled cheat database, mapper-aware disassembly, and a byte-exact rebuildable-project disassembler.
|
|
14
|
+
- **Reverse-engineering analysis engine (all 14 platforms)** — control-flow graphs, deep cross-references, auto-detected functions, a one-shot structural map, and a Ghidra **decompiler** (C-like pseudocode): `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`. Understand *how* a routine works before you touch it — no $3,000 IDA license, no install.
|
|
15
|
+
- **Convert assets** — PNG → platform tiles/tilemaps, quantize-to-palette, audio importers (BRR for SNES, XGM2 PCM for Genesis).
|
|
16
|
+
|
|
17
|
+
Point any coding agent at it three ways:
|
|
10
18
|
|
|
11
19
|
- **Plain HTTP** — `POST http://127.0.0.1:7331/tool/{name}`; browse/try every tool at `/documentation`.
|
|
12
20
|
- **Agent Skill** — `GET /skills/romdev/SKILL.md` (the [Agent Skills](https://agentskills.io) standard; save it to your skills dir as `skills/romdev/SKILL.md`; ~100 tokens until invoked).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.2",
|
|
4
4
|
"description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/analysis/analyze.js
CHANGED
|
@@ -178,7 +178,20 @@ export async function analyzeStructure(romPath, platformOverride) {
|
|
|
178
178
|
* entry carries {from (vaddr base), delta (vaddr-paddr), to}. Returns
|
|
179
179
|
* { paddr, vbase } where paddr = vaddr-delta is the raw-file offset and vbase
|
|
180
180
|
* (= delta) is the address byte 0 of the file maps to on the CPU bus. */
|
|
181
|
-
|
|
181
|
+
/** Platforms whose cartridge maps 1:1 to the CPU bus (file offset == CPU
|
|
182
|
+
* address, base 0). For these we DISTRUST Rizin's IO-map delta: some of Rizin's
|
|
183
|
+
* loaders (notably the Mega Drive loader) split the image into vtable/header/
|
|
184
|
+
* text SEGMENTS and report a non-zero delta on the code segment (e.g. 0x200 for
|
|
185
|
+
* Genesis), but the raw file we hand the decompiler loads flat at VMA 0 — so the
|
|
186
|
+
* vaddr IS the file offset and any delta is a lie for our purposes. Forcing
|
|
187
|
+
* identity here fixes the "+0x200 shifted decompile" bug (a code vaddr would
|
|
188
|
+
* otherwise resolve to vaddr-0x200, the WRONG function). */
|
|
189
|
+
export const FLAT_CPU_MAP = new Set(["genesis", "sms", "gg", "msx", "gb", "gbc"]);
|
|
190
|
+
|
|
191
|
+
export async function vaMapping(romBytes, arch, bits, vaddr, platform) {
|
|
192
|
+
// Flat-cartridge platforms: file offset == CPU address. Ignore Rizin's
|
|
193
|
+
// segment deltas entirely.
|
|
194
|
+
if (FLAT_CPU_MAP.has(platform)) return { paddr: vaddr, vbase: 0 };
|
|
182
195
|
let maps;
|
|
183
196
|
try {
|
|
184
197
|
maps = await runRizinJson({ romBytes, arch, bits, commands: "omlj" });
|
|
@@ -191,6 +204,69 @@ async function vaMapping(romBytes, arch, bits, vaddr) {
|
|
|
191
204
|
return { paddr: vaddr, vbase: 0 };
|
|
192
205
|
}
|
|
193
206
|
|
|
207
|
+
/** Build a CPU-ADDRESSED sparse image of a SNES cart for the decompiler.
|
|
208
|
+
*
|
|
209
|
+
* SNES is banked: the langid is `65816:LE:24:snes` (24-bit space). If we hand
|
|
210
|
+
* the decompiler the flat file, a LoROM function at CPU $00:8000 lives at file
|
|
211
|
+
* 0, but its in-bank `jsr $80xx` operands resolve to file 0x80xx — bank-1 code,
|
|
212
|
+
* a plausible-but-WRONG body. So we lay each ROM chunk at its CPU address
|
|
213
|
+
* (sparse, zero-filled between), making BOTH the function address and every
|
|
214
|
+
* in-bank/JSL operand resolve. ~2x ROM size; fine at SNES cart sizes.
|
|
215
|
+
*
|
|
216
|
+
* Mirrors the detection/fold in disasm.js's mapSnesAddress (kept local to avoid
|
|
217
|
+
* a circular import: disasm.js imports analyze.js).
|
|
218
|
+
*
|
|
219
|
+
* The image is laid out BY CPU address, so the decompiler offset for a CPU
|
|
220
|
+
* address is the address itself (24-bit). @returns {{ image: Uint8Array, isLo:boolean }}
|
|
221
|
+
*/
|
|
222
|
+
export function buildSnesCpuImage(romBytes, mapperHint) {
|
|
223
|
+
const copierOff = (romBytes.length % 0x8000 === 0x200) ? 0x200 : 0;
|
|
224
|
+
let isLo;
|
|
225
|
+
if (mapperHint === "lorom") isLo = true;
|
|
226
|
+
else if (mapperHint === "hirom") isLo = false;
|
|
227
|
+
else {
|
|
228
|
+
const loByte = romBytes[copierOff + 0x7FC0 + 0x15];
|
|
229
|
+
const hiByte = romBytes[copierOff + 0xFFC0 + 0x15];
|
|
230
|
+
const detLo = loByte === 0x20 || loByte === 0x30 || loByte === 0x32;
|
|
231
|
+
const detHi = hiByte === 0x21 || hiByte === 0x31;
|
|
232
|
+
isLo = detHi && !detLo ? false : true; // default LoROM when ambiguous
|
|
233
|
+
}
|
|
234
|
+
const body = romBytes.subarray(copierOff);
|
|
235
|
+
|
|
236
|
+
if (isLo) {
|
|
237
|
+
// LoROM: 32KB file chunk N maps to CPU bank N, $8000-$FFFF. Banks $80-$FF
|
|
238
|
+
// MIRROR $00-$7F (the FastROM image), and code commonly runs there (a JML to
|
|
239
|
+
// $F9xxxx is bank 0x79's ROM via the $80+ mirror). So we lay the full 16MB
|
|
240
|
+
// 24-bit space and mirror each chunk into BOTH its $00-$7F home and its
|
|
241
|
+
// $80-$FF twin — otherwise a reference into the high half "can't load N
|
|
242
|
+
// bytes" and the decompiler bails.
|
|
243
|
+
const fileBanks = Math.ceil(body.length / 0x8000); // ROM chunks (≤128)
|
|
244
|
+
const image = new Uint8Array(0x1000000); // full 16MB CPU space
|
|
245
|
+
for (let b = 0; b < fileBanks; b++) {
|
|
246
|
+
const src = body.subarray(b * 0x8000, (b + 1) * 0x8000);
|
|
247
|
+
const lo = (b & 0x7F); // home bank $00-$7F
|
|
248
|
+
image.set(src, lo * 0x10000 + 0x8000); // $lo:8000
|
|
249
|
+
image.set(src, (lo | 0x80) * 0x10000 + 0x8000); // $(lo|80):8000 mirror
|
|
250
|
+
}
|
|
251
|
+
return { image, isLo: true };
|
|
252
|
+
}
|
|
253
|
+
// HiROM: file 64KB chunk N is CPU bank $C0+N ($0000-$FFFF, the primary image),
|
|
254
|
+
// mirrored to bank $40+N. The upper half of each chunk also appears at
|
|
255
|
+
// $00-$3F:$8000-$FFFF and $80-$BF:$8000-$FFFF. Lay the full 16MB space and
|
|
256
|
+
// mirror so any of those references resolve.
|
|
257
|
+
const fileBanks = Math.ceil(body.length / 0x10000); // 64KB chunks
|
|
258
|
+
const image = new Uint8Array(0x1000000);
|
|
259
|
+
for (let b = 0; b < fileBanks; b++) {
|
|
260
|
+
const src = body.subarray(b * 0x10000, (b + 1) * 0x10000);
|
|
261
|
+
image.set(src, (0xC0 + b) * 0x10000); // $C0+b: full bank (primary)
|
|
262
|
+
image.set(src, (0x40 + b) * 0x10000); // $40+b: mirror
|
|
263
|
+
const upper = src.subarray(0x8000); // $8000-$FFFF half
|
|
264
|
+
image.set(upper, b * 0x10000 + 0x8000); // $00+b:8000 mirror
|
|
265
|
+
image.set(upper, (0x80 + b) * 0x10000 + 0x8000); // $80+b:8000 mirror
|
|
266
|
+
}
|
|
267
|
+
return { image, isLo: false };
|
|
268
|
+
}
|
|
269
|
+
|
|
194
270
|
/**
|
|
195
271
|
* Decompile the function containing `address` to C pseudocode (Ghidra).
|
|
196
272
|
* @returns {{platform, langid, address, code, warnings, qualityNote}}
|
|
@@ -202,13 +278,37 @@ export async function analyzeDecompile(romPath, address, platformOverride) {
|
|
|
202
278
|
if (!SLEIGH_LANGID[platform]) throw new Error(`analyze decompile: unsupported platform '${platform}'`);
|
|
203
279
|
const romBytes = new Uint8Array(await readFile(romPath));
|
|
204
280
|
|
|
281
|
+
// SNES: banked 24-bit space. `address` is a LoROM/HiROM CPU address (what
|
|
282
|
+
// target='functions'/'cfg' report). Lay the cart out by CPU address so BOTH
|
|
283
|
+
// the function address AND its in-bank/JSL operands resolve, then decompile at
|
|
284
|
+
// the CPU address directly. (Flat-at-0 would decompile file[address] — the
|
|
285
|
+
// wrong bank — and mis-label every operand.)
|
|
286
|
+
if (platform === "snes") {
|
|
287
|
+
const { image } = buildSnesCpuImage(romBytes);
|
|
288
|
+
// The image is laid out by CPU address, so the file offset IS the address.
|
|
289
|
+
const imgOff = address >>> 0;
|
|
290
|
+
if (imgOff < 0 || imgOff >= image.length) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`decompile: SNES address ${hx(address)} is outside the ${image.length}-byte CPU image ` +
|
|
293
|
+
`(is it a valid LoROM/HiROM code address?).`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const rs = await decompileFunction({ platform, romBytes: image, fileOffset: imgOff });
|
|
297
|
+
return {
|
|
298
|
+
platform, langid: rs.langid,
|
|
299
|
+
address, addressHex: hx(address),
|
|
300
|
+
code: rs.code, warnings: rs.warnings,
|
|
301
|
+
qualityNote: "medium (65816 variable register width)",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
205
305
|
// Use rizin's loader mapping to turn the VA (what the user sees from
|
|
206
306
|
// target='functions') into the file offset the raw decompiler image needs.
|
|
207
307
|
// PCE uses the 6502 plugin only for the map/loader (HuC6280 decode is the
|
|
208
308
|
// decompiler's job via SLEIGH) — its flat image bases at 0 either way.
|
|
209
309
|
const arch = RIZIN_ARCH[platform] ?? "6502";
|
|
210
310
|
const bits = { arm: 32, m68k: 32, snes: 16 }[arch];
|
|
211
|
-
const { paddr, vbase } = await vaMapping(romBytes, arch, bits, address);
|
|
311
|
+
const { paddr, vbase } = await vaMapping(romBytes, arch, bits, address, platform);
|
|
212
312
|
if (paddr < 0 || paddr >= romBytes.length) {
|
|
213
313
|
throw new Error(
|
|
214
314
|
`decompile: address ${hx(address)} maps to file offset ${paddr}, outside the ` +
|
package/src/http/skill-doc.js
CHANGED
|
@@ -19,6 +19,7 @@ import { toolJsonSchema } from "./tool-registry.js";
|
|
|
19
19
|
export const mcpPreamble = [
|
|
20
20
|
"romdev: homebrew retro game development + reverse-engineering for coding agents.",
|
|
21
21
|
"All ~32 tools register at session init — call any by name directly, no loading step. Each is a domain VERB with an operation axis: memory({op}), build({output}), breakpoint({on}), cpu({op}), sprites({op}), tiles({op}), disasm({target}), romPatch({op}), …",
|
|
22
|
+
"RE engine (all 14 platforms): disasm({target:'functions'}) auto-detects functions, disasm({target:'cfg'}) graphs control flow, disasm({target:'xrefs'}) finds cross-references, disasm({target:'decompile'}) emits Ghidra C pseudocode, symbols({op:'analyze'}) maps a ROM's structure in one call.",
|
|
22
23
|
"catalog({op:'categories'}) maps the tools by purpose (a guide, not a gate); catalog({op:'status'}) is a session re-orient.",
|
|
23
24
|
].join("\n");
|
|
24
25
|
|
|
@@ -27,6 +28,7 @@ export const mcpPreamble = [
|
|
|
27
28
|
*/
|
|
28
29
|
export const skillPreamble = [
|
|
29
30
|
"romdev gives you homebrew retro game development + reverse-engineering for ~14 platforms (NES, SNES, Game Boy, Genesis, GBA, Atari, C64, and more) — build, run, screenshot, inspect, patch, disassemble, convert assets, drive emulators.",
|
|
31
|
+
"It also ships a full RE analysis engine (Rizin + Ghidra, all 14 platforms): control-flow graphs, cross-references, auto-detected functions, a one-shot structural map, and a C-pseudocode decompiler — `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`.",
|
|
30
32
|
"",
|
|
31
33
|
"## Prerequisite: romdev runs LOCALLY (same machine as you)",
|
|
32
34
|
"romdev bundles every compiler + emulator as WASM and runs them in-process — that engine lives in the romdev SERVER, started once with `npx romdevtools` (listens on http://localhost:7331; no other install, no host gcc/emulator needed). If a call gets connection-refused, it isn't running — start it.",
|