romdevtools 0.40.1 → 0.41.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 +97 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/analysis/analyze.js +405 -46
- package/src/analysis/rizin.js +13 -1
- package/src/cores/capabilities.js +218 -0
- package/src/mcp/tools/disasm.js +23 -4
- package/src/mcp/tools/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/rendering-context.js +5 -4
- package/src/mcp/tools/watch-memory.js +144 -2
- package/src/mcp/util.js +37 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/toolchains/_worker/pool.js +41 -3
package/AGENTS.md
CHANGED
|
@@ -66,11 +66,11 @@ Skip playtest only when there's clearly no human in the loop: CI runs, automated
|
|
|
66
66
|
- `input` — drive controllers, look up hardware bit layouts. `navigate` walks menus by advancing on SCREEN CHANGE (not fixed frames) and reports whether each press was consumed — the fast, reliable way to script a UI.
|
|
67
67
|
- `state` — savestates and forensic state inspection (`state({op:'save'})`, `state({op:'load'})`, `state({op:'export'})` a slot to disk without touching the live host, `state({op:'list'})`, `state({op:'dump'})`)
|
|
68
68
|
- `memory` — read/write VRAM/OAM/CGRAM/ARAM and other regions (all 14 platforms). `memory({op:'read'})` takes `offsets:[…]` to batch scattered reads in one call. **`memory({op:'search'})`/`memory({op:'searchNext'})`** = the Cheat-Engine value-search loop ("find the address of X, narrow as X changes") — relative compares (`inc`/`dec`/`changed`) work as the FIRST narrow (baselines recorded at seed), and `as:'bcd'`/`as:'digits'` search packed-BCD scores and digit-per-byte HUD buffers (any constant tile base) when stored ≠ displayed. **`memory({op:'searchUnknown'})`** is the unknown-initial-value hunt — seed the whole region with no value, then narrow by `dec`/`inc`/`changed` across events (the value you can't read off the HUD). **`memory({op:'readCart'})`** reads the loaded cart image to confirm a patch is live (pass `{cpuAddress, bank}` to read a banked CPU address on NES/SNES). **`memory({op:'classify'})`** says whether bytes look like ASCII/code/tile-data (kills the "found table that's really a string" trap). `memory({op:'snapshot'})` + `memory({op:'diff'})` answer "which bytes changed across this event?" (diff defaults to a clustered summary with stride detection; small clusters carry before/after hex, `minDelta` filters churn, and predicate filters — `changeDir:'inc'|'dec'`, `deltaEq`, `beforeMin/Max`, `afterMin/Max` — keep only the bytes that moved the way you expect; `outputPath`+`echo:false` route the full list to disk); **`memory({op:'diffRuns', portsA, portsB?})`** answers "which byte does this INPUT drive?" in one call (same start state run twice under two inputs, only the divergent bytes return); `state({op:'diff'})` is the coarse whole-machine version. Reads routed to disk take `echo:false` to skip the inline hex.
|
|
69
|
-
- `debug` — **`frame({op:'verify'})`** (NO-VISION render-health: one call answers "is the game actually rendering / alive?" on all 14 — fuses a framebuffer pixel scan with the per-platform render-enable/NMI decode; `{verified:true|false|null, issues[], pixels, render}`, frame-0-guarded so it never cries wolf on boot), `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read'})` (all 14), `audioDebug({op:'inspect'})` (the 12 systems with a sound chip — all but Atari 2600/7800; pass `frames:N` to TRACE a per-channel note-timeline for headless melody asserts), `background({view:'renderState'})`, `breakpoint({on:'write'})` (write watchpoint, all 14; EVERY hit on EVERY platform carries `registersAtHit` — the register file frozen at the hit instant, the only honest read since live regs drift after a hit — and the CPU stays frozen until the hit is cleared), **`watch({on:'dma', precision:'sampled'})`** (Genesis: which ROM offset a VRAM graphic was DMA'd from), **`watch({on:'copy'})`** (ALL 14: every write landing in a VRAM window logged with the EXECUTING instruction's PC — the generic 'which routine uploads this graphic?'; port-based video memory hooked in-core incl. the SNES DMA path, CPU-mapped VRAM via the range log), **`disasm({target:'bytes'|'rom'|'references'|'project'})`** (ALL 14 — native binutils objdump per CPU, incl. GBA ARM7/Thumb; the byte-exact `disasm({target:'project'})` reassembles through native as/ld/objcopy; banked carts — NES mappers, SNES LoROM, GB MBC, Sega mapper, MSX megaROM, 2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards — are split and reference-scanned PER BANK, refs tagged `prgBank`/`romBank`), plus the **Rizin/Ghidra RE engine** `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` (ALL 14 — control-flow graphs, deep xrefs, auto-detected functions, and Ghidra C pseudocode; quality excellent on GBA/Genesis, rough on 6502) + `symbols({op:'analyze'})` (one-shot structural map), `symbols({op})` lookup, `background({view:'rendered'})`, plus **`cheats({op})`** (`cheats({op:'lookup'})` = a free labeled RAM/code map for known ROMs, `cheats({op:'search'})` to fuzzy-find a game by name, `cheats({op:'apply'})`/`cheats({op:'clear'})` non-destructively, `cheats({op:'make'})` to create codes)
|
|
69
|
+
- `debug` — **`frame({op:'verify'})`** (NO-VISION render-health: one call answers "is the game actually rendering / alive?" on all 14 — fuses a framebuffer pixel scan with the per-platform render-enable/NMI decode; `{verified:true|false|null, issues[], pixels, render}`, frame-0-guarded so it never cries wolf on boot), `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read'})` (all 14), `audioDebug({op:'inspect'})` (the 12 systems with a sound chip — all but Atari 2600/7800; pass `frames:N` to TRACE a per-channel note-timeline for headless melody asserts), `background({view:'renderState'})`, `breakpoint({on:'write'})` (write watchpoint, all 14; EVERY hit on EVERY platform carries `registersAtHit` — the register file frozen at the hit instant, the only honest read since live regs drift after a hit — and the CPU stays frozen until the hit is cleared), **`watch({on:'dma', precision:'sampled'})`** (Genesis: which ROM offset a VRAM graphic was DMA'd from), **`watch({on:'copy'})`** (ALL 14: every write landing in a VRAM window logged with the EXECUTING instruction's PC — the generic 'which routine uploads this graphic?'; port-based video memory hooked in-core incl. the SNES DMA path, CPU-mapped VRAM via the range log), **`disasm({target:'bytes'|'rom'|'references'|'project'})`** (ALL 14 — native binutils objdump per CPU, incl. GBA ARM7/Thumb; the byte-exact `disasm({target:'project'})` reassembles through native as/ld/objcopy; banked carts — NES mappers, SNES LoROM, GB MBC, Sega mapper, MSX megaROM, 2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards — are split and reference-scanned PER BANK, refs tagged `prgBank`/`romBank`), plus the **Rizin/Ghidra RE engine** `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'|'resolveJumptable'})` (ALL 14 — control-flow graphs, deep xrefs, auto-detected functions [sorted real-code-first, `looksLikeData` flagged], and Ghidra C pseudocode; quality excellent on GBA/Genesis, rough on 6502; decompile output NAMES hardware registers [`PPUMASK` not `*0x2001`] and on the 6502 family folds SLEIGH clutter to readable C99 [`uint8_t`, `zp_FD`]; `resolveJumptable` resolves computed dispatchers live via `breakpoint({on:'jumptable'})`) + `symbols({op:'analyze'})` (one-shot structural map), `symbols({op})` lookup, `background({view:'rendered'})`, plus **`cheats({op})`** (`cheats({op:'lookup'})` = a free labeled RAM/code map for known ROMs, `cheats({op:'search'})` to fuzzy-find a game by name, `cheats({op:'apply'})`/`cheats({op:'clear'})` non-destructively, `cheats({op:'make'})` to create codes)
|
|
70
70
|
- `assets` — convert PNGs to tiles (`encodeArt`/`importArt`), WAVs to BRR, identify ROMs (`cart({op:'identify'})`), plus the hacking toolkit (`romPatch({op})` — write/writeMany/spliceCHR/relocate/makeStored/findFree/findPointer/diff, `assembleSnippet`, `cart({op:'extract'})`, `cart({op:'wrap'})`)
|
|
71
71
|
- `project` — the example-game library (`examples`: list / fork / show, plus the legacy snippet ops)
|
|
72
72
|
- `show` — `playtest({op})`: `op:'open'` opens the live SDL window for a human, `op:'stop'` closes it, `op:'status'` reports liveness, `op:'framebuffer'` captures exactly what the human's window shows
|
|
73
|
-
- `advanced` — `runUntil`, **`watch({on:'mem'|'range'|'pc'})`** (LOG-ALL tracing; `range`/`pc` take **`fromState`**/`fromStatePath` to trace from a restored savestate moment), **`breakpoint({on:'write'})`** (the EXACT instruction that wrote a byte, via a core watchpoint — fixes the frame-sampled-PC problem; runs to END OF FRAME and reports the LAST matching write with `hits`=count; `condition:'increase'|'decrease'|'equals'`+`conditionValue` filters to the MEANINGFUL write — the score going UP, not the per-frame restore churn (core-level on all 14, `oldValueByte` reported); `precision:'sampled'` is the cheap frame-PC version; on a `pressDuring` run pass **`abortIf:[{region,offset,label}]`** to stop early if the driven scenario derails — a guard byte changing returns `{aborted, abortedBy, before, after}` instead of burning all `maxFrames` on a meaningless `found:false`), **`breakpoint({on:'pc'})`** (execution breakpoint — freeze the CPU AT an instruction and read its registers), **`breakpoint({on:'read'})`** (the EXACT instruction that read a byte), **`frame({op:'stepInstruction'})`** (CPU single-step) — all 14 platforms; input recording
|
|
73
|
+
- `advanced` — `runUntil`, **`watch({on:'mem'|'range'|'pc'})`** (LOG-ALL tracing; `range`/`pc` take **`fromState`**/`fromStatePath` to trace from a restored savestate moment), **`breakpoint({on:'write'})`** (the EXACT instruction that wrote a byte, via a core watchpoint — fixes the frame-sampled-PC problem; runs to END OF FRAME and reports the LAST matching write with `hits`=count; `condition:'increase'|'decrease'|'equals'`+`conditionValue` filters to the MEANINGFUL write — the score going UP, not the per-frame restore churn (core-level on all 14, `oldValueByte` reported); `precision:'sampled'` is the cheap frame-PC version; on a `pressDuring` run pass **`abortIf:[{region,offset,label}]`** to stop early if the driven scenario derails — a guard byte changing returns `{aborted, abortedBy, before, after}` instead of burning all `maxFrames` on a meaningless `found:false`), **`breakpoint({on:'pc'})`** (execution breakpoint — freeze the CPU AT an instruction and read its registers), **`breakpoint({on:'read'})`** (the EXACT instruction that read a byte), **`breakpoint({on:'jumptable'})`** (RESOLVE a computed-jump dispatcher static analysis can't follow — `JMP (table,X)` / RTS-trick state machines, script/battle VMs: break at the dispatcher, single-step through the indirect transfer, record the COMPUTED targets live across frames/inputs; the varying arms are isolated from fixed trampolines; `disasm({target:'resolveJumptable'})` points here. No static-only tool can do this — it needs the live emulator), **`frame({op:'stepInstruction'})`** (CPU single-step) — all 14 platforms; input recording
|
|
74
74
|
|
|
75
75
|
**"Disassemble this NES ROM"** is now just: `disasm({target:'rom', path, startAddress, length})`. No discovery step.
|
|
76
76
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,103 @@ 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.41.0 — 2026-06-12
|
|
8
|
+
|
|
9
|
+
### RE engine round — bank-aware decompile, live jumptable recovery, readable 6502 output
|
|
10
|
+
|
|
11
|
+
A correctness + readability pass across the whole reverse-engineering engine,
|
|
12
|
+
plus the differentiator no static tool has: **resolving computed jumps with the
|
|
13
|
+
live emulator.** All 14 platforms; no `romdev-analysis*` package changes (the
|
|
14
|
+
work is in the address-mapping JS, the decompile post-passes, and the live-debug
|
|
15
|
+
tools).
|
|
16
|
+
|
|
17
|
+
#### New — `breakpoint({on:'jumptable', address})`: live computed-jumptable recovery
|
|
18
|
+
|
|
19
|
+
Static analysis follows direct addressing only, so a game's *hottest* routines —
|
|
20
|
+
state machines, script / event VMs, battle engines that dispatch through
|
|
21
|
+
`JMP (table,X)` or an RTS-trick — decompile to `(*_IRQ)()` + "Could not recover
|
|
22
|
+
jumptable." romdev has a **live emulator**, so it resolves them dynamically: break
|
|
23
|
+
at the dispatcher, single-step through the indirect transfer, and record the PC
|
|
24
|
+
it actually lands on — accumulated across frames/inputs. Fixed trampolines (the
|
|
25
|
+
compiler's pointer-call shim, return paths) are filtered out by what *doesn't*
|
|
26
|
+
vary; the destinations that vary hit-to-hit are the real switch arms, ranked by
|
|
27
|
+
hit count. Drive more game states (`pressDuring` / `fromState`) to surface rarer
|
|
28
|
+
arms. **No standalone tool (IDA / Ghidra / Binary Ninja) can do this** — it needs
|
|
29
|
+
an emulator in the loop. `disasm({target:'resolveJumptable', address})` is the
|
|
30
|
+
static-side alias that redirects to it.
|
|
31
|
+
|
|
32
|
+
#### New — `disasm({target:'decompile'})` reads cleaner
|
|
33
|
+
|
|
34
|
+
- **Hardware registers are named.** MMIO refs Ghidra emits as raw addresses
|
|
35
|
+
(`*0x2001`, `uRAM400e`) become the register name (`PPUMASK`, `NOISE_LO`), with a
|
|
36
|
+
`/* hw registers: … */` legend — on the 9 platforms with a register map
|
|
37
|
+
(NES/SNES/Genesis/GB/GBC/SMS/GG/2600/7800/C64).
|
|
38
|
+
- **6502 SLEIGH clutter folds to readable C** (NES/2600/7800/C64/Lynx/PCE). Width
|
|
39
|
+
types become C99 stdint (`uint1`→`uint8_t`, `uint2`→`uint16_t`), redundant
|
|
40
|
+
nested casts collapse (`(uint16_t)(uint8_t)x`→`(uint8_t)x`), and zero-page byte
|
|
41
|
+
refs are named (`cRAM00fd`→`zp_FD`), with a `/* 6502 fold: … */` legend. A real
|
|
42
|
+
banked NES function went from `*(xunknown1 *)(uint2)(uint1)(param_2 - 0xb)` to
|
|
43
|
+
`*(uint8_t *)(zp_FE - 0xb)` — same semantics, far more readable. (The
|
|
44
|
+
carry-flag-16-bit / BCD reconstruction is left to an LLM reading the output;
|
|
45
|
+
rewriting it textually would risk changing semantics.)
|
|
46
|
+
|
|
47
|
+
#### Improved — bank-aware decompile + honest function ranking
|
|
48
|
+
|
|
49
|
+
- **Banked NES `decompile` resolves the bank.** Rizin reports flat-PRG VAs, so a
|
|
50
|
+
flat decode was bank-blind (cross-bank `JSR`/`JMP` landed on the wrong bank).
|
|
51
|
+
`decompile` now lays a real 32 KB CPU window (selected bank @ `$8000` + fixed
|
|
52
|
+
top bank @ `$C000`) so in-bank *and* fixed-bank calls resolve; NROM falls
|
|
53
|
+
through to the flat path. On a real banked game this moved a top-12 function
|
|
54
|
+
list from ~1 readable / 11 garbage to ~10 readable / 2.
|
|
55
|
+
- **`disasm({target:'functions'})` is ranked real-code-first** with a
|
|
56
|
+
`looksLikeData` flag (+ `dataCount`), so giant single-block data folds stop
|
|
57
|
+
crowding out the actual control-flow routines you want.
|
|
58
|
+
|
|
59
|
+
#### Fixed / hardened
|
|
60
|
+
|
|
61
|
+
- **SMD-interleaved Genesis dumps auto-deinterleave.** A `.bin` in the SMD copier
|
|
62
|
+
format (size = N·16 KB + 512, `0xAA 0xBB` magic) read flat decodes to pure
|
|
63
|
+
"bad instruction" garbage; analysis now detects + reverses the interleave and
|
|
64
|
+
warns, so a flat disasm isn't silently wrong.
|
|
65
|
+
- **C64 `.prg` load-address header is stripped** before analysis (the 2-byte load
|
|
66
|
+
address was being analyzed as code), with the base applied so addresses line up.
|
|
67
|
+
- **Worker-pool timeout + recycle.** A whole-ROM `aaa` on a multi-MB ROM that
|
|
68
|
+
never returns no longer wedges the shared WASM analysis pool — the call times
|
|
69
|
+
out, the worker is killed + respawned, and a clean `{ timedOut }` result comes
|
|
70
|
+
back (with a "use a scoped pass" hint) instead of every later `disasm` hanging
|
|
71
|
+
until a manual server restart.
|
|
72
|
+
|
|
73
|
+
#### New op discovery
|
|
74
|
+
|
|
75
|
+
- **`platform({op:'capabilities', platform?})`** — the per-platform op-support
|
|
76
|
+
matrix (CPU family, rendering kind, which introspection/debug ops each core
|
|
77
|
+
actually wires), so an agent can check support before calling instead of
|
|
78
|
+
catching a failure. Unsupported ops now throw a typed, structured error
|
|
79
|
+
(`{ unsupported, platform, op, reason, alternative }`) rather than a bare string.
|
|
80
|
+
|
|
81
|
+
## 0.40.2 — 2026-06-11
|
|
82
|
+
|
|
83
|
+
### Fixed — SNES `disasm({target:'decompile'})` treated the address as a raw file offset
|
|
84
|
+
|
|
85
|
+
On SNES, `decompile` decompiled the function at raw FILE offset `address`, but
|
|
86
|
+
`functions` / `cfg` / `xrefs` all report LoROM/HiROM **CPU** addresses — so the
|
|
87
|
+
documented "decompile an address from `functions`" loop silently returned the
|
|
88
|
+
wrong function (e.g. asking for the entry at CPU `$00:8000` decompiled file
|
|
89
|
+
`0x8000` = CPU `$01:8000`, the wrong bank).
|
|
90
|
+
|
|
91
|
+
Fix: SNES now lays the cart out by **24-bit CPU address** — each ROM chunk is
|
|
92
|
+
placed at its CPU bank (LoROM `$bank:8000`, HiROM `$C0+bank`), with the
|
|
93
|
+
`$80-$FF` FastROM (and HiROM `$40-$7F`) mirrors filled in — then decompiles at
|
|
94
|
+
the CPU address directly. This fixes both the function lookup AND in-bank /
|
|
95
|
+
`jsl` operand resolution (a flat-at-0 image would mis-label every cross-bank
|
|
96
|
+
call). The CPU-addressed image is ~16MB sparse (zero-filled between banks); a
|
|
97
|
+
real 4MB cart decompiles in ~1.2s. (No change to the `romdev-analysis*`
|
|
98
|
+
packages — the fix is in the address-mapping JS.)
|
|
99
|
+
|
|
100
|
+
Note: 65816 decompiler output is medium quality (variable register width, BCD/
|
|
101
|
+
decimal-flag expansion, direct-page guards) — for SNES, lean on `cfg` / `xrefs`
|
|
102
|
+
+ targeted `decompile` of leaf routines over big dispatchers.
|
|
103
|
+
|
|
7
104
|
## 0.40.1 — 2026-06-11
|
|
8
105
|
|
|
9
106
|
### Fixed — Genesis `disasm({target:'decompile'})` was shifted +0x200
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ npx romdevtools
|
|
|
11
11
|
- **Build** — bundled per-platform toolchains (cc65, SDCC, RGBDS, asar, vasm, SGDK, PVSnesLib, libtonc, …) as WASM. Write source, compile, get a real ROM.
|
|
12
12
|
- **Run + see + drive** — load the ROM into an emulated console (libretro cores as WASM), step frames, screenshot, script controller input.
|
|
13
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.
|
|
14
|
+
- **Reverse-engineering analysis engine (all 14 platforms)** — control-flow graphs, deep cross-references, auto-detected functions (ranked real-code-first), a one-shot structural map, and a Ghidra **decompiler** (C-like pseudocode, with hardware registers named and 6502 SLEIGH clutter folded to readable C): `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`. And the piece no static tool has: **live computed-jumptable recovery** — `breakpoint({on:'jumptable'})` runs the emulator to resolve the `JMP (table,X)` / RTS-trick dispatchers (state machines, script/battle VMs) that static analysis collapses to "could not recover." Understand *how* a routine works before you touch it — no $3,000 IDA license, no install.
|
|
15
15
|
- **Convert assets** — PNG → platform tiles/tilemaps, quantize-to-palette, audio importers (BRR for SNES, XGM2 PCM for Genesis).
|
|
16
16
|
|
|
17
17
|
Point any coding agent at it three ways:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
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",
|