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 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.40.1",
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",