romdevtools 0.30.0 → 0.40.1

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.
Files changed (77) hide show
  1. package/AGENTS.md +12 -3
  2. package/CHANGELOG.md +89 -13
  3. package/README.md +11 -2
  4. package/examples/gb/templates/tile_engine.c +1 -1
  5. package/examples/gbc/templates/tile_engine.c +1 -1
  6. package/examples/genesis/templates/two_plane_parallax.c +4 -4
  7. package/examples/nes/templates/tile_engine.c +1 -1
  8. package/package.json +3 -1
  9. package/src/analysis/analyze.js +276 -0
  10. package/src/analysis/decompile.js +108 -0
  11. package/src/analysis/decompiler/sleigh/6502.cspec +34 -0
  12. package/src/analysis/decompiler/sleigh/6502.ldefs +33 -0
  13. package/src/analysis/decompiler/sleigh/6502.pspec +16 -0
  14. package/src/analysis/decompiler/sleigh/6502.sla +3414 -0
  15. package/src/analysis/decompiler/sleigh/65816-snes.pspec +249 -0
  16. package/src/analysis/decompiler/sleigh/65816.cspec +17 -0
  17. package/src/analysis/decompiler/sleigh/65816.ldefs +15 -0
  18. package/src/analysis/decompiler/sleigh/65816.sla +23670 -0
  19. package/src/analysis/decompiler/sleigh/65c02.sla +4683 -0
  20. package/src/analysis/decompiler/sleigh/68000.cspec +67 -0
  21. package/src/analysis/decompiler/sleigh/68000.ldefs +68 -0
  22. package/src/analysis/decompiler/sleigh/68000.pspec +9 -0
  23. package/src/analysis/decompiler/sleigh/68000_register.cspec +118 -0
  24. package/src/analysis/decompiler/sleigh/68040.sla +81693 -0
  25. package/src/analysis/decompiler/sleigh/ARM.ldefs +377 -0
  26. package/src/analysis/decompiler/sleigh/ARM4t_le.sla +53758 -0
  27. package/src/analysis/decompiler/sleigh/ARM_v45.cspec +209 -0
  28. package/src/analysis/decompiler/sleigh/ARM_v45.pspec +40 -0
  29. package/src/analysis/decompiler/sleigh/ARMt_v45.pspec +43 -0
  30. package/src/analysis/decompiler/sleigh/HuC6280.cspec +67 -0
  31. package/src/analysis/decompiler/sleigh/HuC6280.ldefs +18 -0
  32. package/src/analysis/decompiler/sleigh/HuC6280.pspec +150 -0
  33. package/src/analysis/decompiler/sleigh/HuC6280.sla +7524 -0
  34. package/src/analysis/decompiler/sleigh/sm83.cspec +70 -0
  35. package/src/analysis/decompiler/sleigh/sm83.ldefs +29 -0
  36. package/src/analysis/decompiler/sleigh/sm83.pspec +19 -0
  37. package/src/analysis/decompiler/sleigh/sm83.sla +7695 -0
  38. package/src/analysis/decompiler/sleigh/z80.cspec +122 -0
  39. package/src/analysis/decompiler/sleigh/z80.ldefs +57 -0
  40. package/src/analysis/decompiler/sleigh/z80.pspec +43 -0
  41. package/src/analysis/decompiler/sleigh/z80.sla +20518 -0
  42. package/src/analysis/decompiler/wasm/decompile.js +2 -0
  43. package/src/analysis/decompiler/wasm/decompile.wasm +0 -0
  44. package/src/analysis/rizin.js +129 -0
  45. package/src/analysis/wasm/rizin.js +6032 -0
  46. package/src/analysis/wasm/rizin.wasm +0 -0
  47. package/src/http/routes.js +1 -1
  48. package/src/http/skill-doc.js +3 -1
  49. package/src/mcp/tools/cart-parts.js +5 -2
  50. package/src/mcp/tools/disasm.js +32 -5
  51. package/src/mcp/tools/font-map.js +3 -3
  52. package/src/mcp/tools/index.js +2 -2
  53. package/src/mcp/tools/project.js +1 -1
  54. package/src/mcp/tools/reinject.js +1 -1
  55. package/src/mcp/tools/symbols.js +10 -4
  56. package/src/mcp/tools/trace-vram-source.js +1 -1
  57. package/src/mcp/tools/watch-memory.js +1 -1
  58. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +48 -3
  59. package/src/platforms/atari2600/MENTAL_MODEL.md +6 -0
  60. package/src/platforms/atari7800/MENTAL_MODEL.md +6 -0
  61. package/src/platforms/c64/MENTAL_MODEL.md +6 -0
  62. package/src/platforms/gb/MENTAL_MODEL.md +6 -0
  63. package/src/platforms/gb/lib/c/README.md +1 -1
  64. package/src/platforms/gba/MENTAL_MODEL.md +7 -1
  65. package/src/platforms/gbc/MENTAL_MODEL.md +6 -0
  66. package/src/platforms/gbc/lib/c/README.md +1 -1
  67. package/src/platforms/genesis/MENTAL_MODEL.md +8 -2
  68. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  69. package/src/platforms/genesis/lib/wram.s +1 -1
  70. package/src/platforms/gg/MENTAL_MODEL.md +6 -0
  71. package/src/platforms/lynx/MENTAL_MODEL.md +6 -0
  72. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  73. package/src/platforms/nes/MENTAL_MODEL.md +6 -0
  74. package/src/platforms/pce/MENTAL_MODEL.md +6 -0
  75. package/src/platforms/sms/MENTAL_MODEL.md +6 -0
  76. package/src/platforms/snes/MENTAL_MODEL.md +10 -4
  77. package/src/toolchains/_worker/wasm-worker.js +5 -0
package/AGENTS.md CHANGED
@@ -66,7 +66,7 @@ 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`), `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'})` (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)
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
@@ -108,8 +108,17 @@ questions, and running both early is normal.** A good default:
108
108
  cheats don't cover: `disasm({target:'project'})` for a rebuildable project,
109
109
  `disasm({target:'references'})` for "what touches this address", `breakpoint({on:'write'})` for the exact
110
110
  instruction that wrote a byte, `watch({on:'mem'})`/`breakpoint({on:'write',precision:'sampled'})` to find an address
111
- empirically. For a no-cheats game or a logic/text/graphics change, this is
112
- where the real work is start here, don't wait on a cheat lookup.
111
+ empirically. For STRUCTURE "what are the functions, how do they call each
112
+ other, what's the control flow"use the Rizin analysis ops: `symbols({op:'analyze'})`
113
+ for a one-shot map (functions + entrypoints), `disasm({target:'functions'})` for the
114
+ auto-detected function list, `disasm({target:'cfg', address})` for a function's basic-block
115
+ graph, `disasm({target:'xrefs', address})` for every cross-reference TO an address (deeper
116
+ than the da65 `references` scan — it follows the analysis graph). For C-like PSEUDOCODE,
117
+ `disasm({target:'decompile', address})` runs Ghidra's decompiler (carries the decompiler's
118
+ own WARNINGs; quality excellent on GBA/Genesis, good on GB/Z80, rough on 6502). All
119
+ Rizin/Ghidra analysis ops cover 14/14 platforms. For a no-cheats game or a
120
+ logic/text/graphics change, this is where the real work is — start here, don't wait on a
121
+ cheat lookup.
113
122
  4. **VERIFY before patching**: `memory({op:'write'})` the address live and watch the effect
114
123
  (cheat labels are *probable* — matched by name, not verified CRC; static
115
124
  "matches the pattern" ≠ "actually runs").
package/CHANGELOG.md CHANGED
@@ -4,10 +4,86 @@ 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.1 — 2026-06-11
8
+
9
+ ### Fixed — Genesis `disasm({target:'decompile'})` was shifted +0x200
10
+
11
+ A Genesis decompile at a caller-supplied address silently returned the function
12
+ 0x200 bytes too low (the wrong one, or an empty `{ return; }` / `halt_baddata()`),
13
+ with no warning. `cfg` / `xrefs` / `functions` on the same address were correct —
14
+ only `decompile` was off.
15
+
16
+ Root cause: Rizin's Mega Drive loader splits a flat `.bin` into vtable / header /
17
+ text segments and reports a non-zero address delta (`0x200`) on the code segment;
18
+ the decompiler's address mapping honored that delta, but the raw image handed to
19
+ Ghidra loads flat at offset 0, so the two disagreed by exactly 0x200. Fix: flat-
20
+ cartridge platforms (Genesis, SMS, Game Gear, MSX, Game Boy / GBC) now force
21
+ file-offset == CPU-address and ignore Rizin's segment delta. The 6502-family
22
+ platforms were unaffected (they use a separate base-address path). Regression
23
+ test added. (No change to the `romdev-analysis` / `romdev-analysis-decompiler`
24
+ packages — the fix is entirely in the address-mapping JS.)
25
+
26
+ ## 0.40.0 — 2026-06-11
27
+
28
+ ### Reverse-engineering analysis engine — control-flow graphs, deep xrefs, function detection, and a decompiler
29
+
30
+ A full open-source RE analysis layer, covering **all 14 platforms** with zero
31
+ proprietary dependencies. Two new binary packages carry the WebAssembly; the
32
+ main package gains four `disasm` targets and one `symbols` op that drive them.
33
+
34
+ - **`disasm({target:'functions'})`** — auto-detected function list
35
+ (`{address, size, nbbs, cc, callers, callees}`): the structural map of an
36
+ unknown ROM, the carve step before you label anything live.
37
+ - **`disasm({target:'cfg', address})`** — basic-block control-flow graph of the
38
+ function at `address` (nodes + typed edges: jump / branch_true / branch_false).
39
+ - **`disasm({target:'xrefs', address})`** — every cross-reference TO `address`,
40
+ following the analysis graph. Deeper than the flat `target:'references'` da65
41
+ operand scan — prefer `xrefs` once a function pass has run, `references` for a
42
+ quick header-less sweep.
43
+ - **`disasm({target:'decompile', address})`** — Ghidra C-like **pseudocode** for
44
+ the function at `address`, with the decompiler's own warnings and a per-CPU
45
+ `qualityNote`. Altitude rule: decompile is for UNDERSTANDING (and as a port
46
+ spec when retargeting to a bigger machine) — `target:'project'` stays the
47
+ byte-exact rebuildable edit path. Quality is excellent on ARM (GBA) and M68K
48
+ (Genesis), good on SM83 (GB/GBC) and Z80 (SMS/GG/MSX), medium on 65816 (SNES)
49
+ and HuC6280 (PC Engine), and rough on the 6502 family (an architecture limit —
50
+ every tool is rough on 6502).
51
+ - **`symbols({op:'analyze'})`** — one-shot structural map of a ROM
52
+ (auto-detected functions + strings + entrypoints), no `.dbg`/`.map` needed.
53
+
54
+ Built from pinned upstreams, fetch-on-demand, never vendored — only the compiled
55
+ artifacts ship (see `scripts/build-rizin.sh`, `scripts/build-decompiler.sh`,
56
+ `scripts/versions.json`):
57
+ - **`romdev-analysis`** — Rizin compiled to WASM (the CFG / xrefs / functions
58
+ engine). LGPL-3.0.
59
+ - **`romdev-analysis-decompiler`** — Ghidra's C++ decompiler compiled to WASM
60
+ (no JVM, no rizin) plus SLEIGH processor tables for all 14 CPUs. Apache-2.0,
61
+ with full per-component attribution in the package NOTICE (Ghidra/NSA, and the
62
+ community SM83 / 65816 / HuC6280 SLEIGH specs).
63
+
64
+ ### Documentation: no commercial game titles in shipped source
65
+
66
+ Swept every shipped tool description, doc, README, mental-model guide, and the
67
+ ROM-hacking playbook for commercial game/franchise names and replaced them with
68
+ generic platform + hardware + mechanic descriptions ("a banked NES racer", "a
69
+ top-down dungeon-crawler shape"). Console and chip names are unchanged — they're
70
+ not anyone's IP. The bundled cheat database (third-party crowd-sourced data) is
71
+ unaffected.
72
+
73
+ ### Tests build their own ROMs (no external fixtures)
74
+
75
+ Tests that needed a real ROM now build one from our **own example sources** at
76
+ runtime instead of depending on a ROM file on disk — so the whole suite runs
77
+ with no external/commercial ROM anywhere. Every previously-skipped fixture-gated
78
+ test now runs: **918 tests, 918 pass, 0 fail, 0 skipped**. (This also surfaced a
79
+ latent fidelity bug the silent skips had hidden: `wrapRomFromParts` dropped the
80
+ iNES battery-SRAM flag on round-trip — now preserved via a new `hasBattery`
81
+ field, and exposed on the `wrapRomFromParts` tool.)
82
+
7
83
  ## 0.30.0 — 2026-06-11
8
84
 
9
85
  ### RE-tooling round — the Cheat-Engine "locate a value + find its writer" workflow
10
- From a Zanac (NES) reverse-engineering feedback batch. Six additions to the
86
+ From an NES reverse-engineering feedback batch. Six additions to the
11
87
  memory/breakpoint primitives:
12
88
  - **`memory({op:'searchUnknown'})`** — the unknown-initial-value hunt: seed the
13
89
  WHOLE region with no value, then narrow across in-game events with
@@ -164,7 +240,7 @@ three guarantees now hold across the whole platform matrix:
164
240
  - `test/regsnap-all-cores.test.js`: live single-step snapshot + freeze proof
165
241
  on 10 platforms (plus the existing gpgx suite for Genesis/SMS/GG).
166
242
 
167
- ### Fixed/Added — gpgx core round (the NBA-Jam-both-consoles feedback): break-instant truth on Genesis/SMS/GG
243
+ ### Fixed/Added — gpgx core round (a both-consoles sports-title feedback): break-instant truth on Genesis/SMS/GG
168
244
  The first core rebuild in this release (gpgx only; pins unchanged, patch extended).
169
245
  - **`registersAtHit` on Genesis/SMS/GG** — `breakpoint({on:'pc'|'write'|'read'})`
170
246
  hits now carry the FULL register file (m68k d0-d7/a0-a7/pc/sr/sp; z80
@@ -333,7 +409,7 @@ of repeated logic errors. The big ones:
333
409
  respond to input, and each platform's audio was captured and RMS-checked.
334
410
  (Atari 2600 has no puzzle genre by design.)
335
411
 
336
- ### Fixed/Added — the 0.27.0 Zanac RE feedback round (banked-NES rebuilds, A/B diff, token cuts)
412
+ ### Fixed/Added — the 0.27.0 NES RE feedback round (banked-NES rebuilds, A/B diff, token cuts)
337
413
  - **Banked NES `disasm({target:'project'})` now emits COMPLETE, working rebuild
338
414
  glue** (the headline ask): a `HEADER` segment with the original 16 iNES bytes,
339
415
  a per-bank `PRGn` segment wrapper for every bank, a multi-bank `nes_rebuild.cfg`
@@ -407,7 +483,7 @@ defaulted to native; this is the docs telling the truth about it.)
407
483
  ## 0.27.0
408
484
 
409
485
  ### Added — `breakpoint(on:'pc', captureMemory:[…])` reads named RAM at the hit
410
- Completes item 2 of the NES Rygar report. 0.26.0 shipped `registersAtHit` (the
486
+ Completes item 2 of an NES action-game RE report. 0.26.0 shipped `registersAtHit` (the
411
487
  break-instant register file) but not the memory half. Now `breakpoint(on:'pc')`
412
488
  takes `captureMemory:[{region,offset,length,label}]` and returns those reads inline
413
489
  as `capturedMemory`, so register + RAM inspection at a PC collapses into ONE call —
@@ -418,7 +494,7 @@ hit frame (stable + what RE needs), documented as such.
418
494
  ## 0.26.0
419
495
 
420
496
  ### Fixed — NES `breakpoint(on:'pc')` now returns reliable break-instant registers
421
- An agent RE'ing NES Rygar found that after a `pc` breakpoint hit, a follow-up
497
+ An agent RE'ing an NES action game found that after a `pc` breakpoint hit, a follow-up
422
498
  `cpu({op:'read'})` returned the **idle-loop PC**, not the breakpoint instruction —
423
499
  the documented "break, then read the live register file" workflow gave end-of-frame
424
500
  state. Root cause: fceumm drains the cycle budget on hit but `retro_run` still
@@ -447,7 +523,7 @@ own frame correctly. Null until a ROM is loaded.
447
523
  ## 0.25.0
448
524
 
449
525
  ### Added — C64 input scripting + verification (RE startup-flow telemetry)
450
- Follow-up to the 0.24.0 C64 keyboard work: an agent RE'ing C64 Uridium could now
526
+ Follow-up to the 0.24.0 C64 keyboard work: an agent RE'ing a C64 shoot-'em-up could now
451
527
  press keys, but couldn't (a) script a keyboard+joystick startup TIMELINE in one
452
528
  call, or (b) tell whether a non-responsive key reached VICE at all. Both added — no
453
529
  core rebuild (the `c64_cia1_regs` region + key matrix already existed):
@@ -520,7 +596,7 @@ or the tool list); full release notes remain in CHANGELOG.md for humans.
520
596
  ## 0.24.0
521
597
 
522
598
  ### Added — C64 keyboard + joyport input (VICE core patch)
523
- An agent RE'ing C64 Uridium could reach the intro via joystick but couldn't ENTER
599
+ An agent RE'ing a C64 shoot-'em-up could reach the intro via joystick but couldn't ENTER
524
600
  gameplay — the game needs **F1** (1 player) + fire on **port 2**, and romdev's
525
601
  input was joypad-mask-only. Many C64 games gate gameplay behind KEYBOARD setup
526
602
  screens that joystick can't reach. The VICE core now exports
@@ -602,7 +678,7 @@ training agents to ignore lint. Now: provably-VRAM dest → warning, plain RAM a
602
678
  repeated starfield + player sprite, hardware scroll only, zero loop-time tilemap
603
679
  writes. Builds clean, renders, scrolls (verified).
604
680
  - Genesis MENTAL_MODEL/TROUBLESHOOTING: "do NOT rewrite tilemaps in the frame
605
- loop", logical-vs-hardware plane size, the correct parallax loop, Sonic-style
681
+ loop", logical-vs-hardware plane size, the correct parallax loop, large-scroller-style
606
682
  column streaming, and a "why does movement feel choppy?" recipe.
607
683
 
608
684
  ### Changed — discoverability (the recurring root cause)
@@ -1020,7 +1096,7 @@ savestate) is now fully supported, with NO new top-level tool:
1020
1096
  - **Honest "no save":** empty `save_ram` now says *why* — "this cart has no battery
1021
1097
  save" / "Atari 2600/7800 & Lynx never had cartridge saves" / "C64 has no battery SRAM (disk/.prg)" — instead of a generic "core didn't expose it." (Confirmed via research +
1022
1098
  core source: no core patches were needed; earlier "broken" readings were
1023
- password-game test carts like Metroid, which correctly have no battery.)
1099
+ password-based NES carts, which correctly have no battery.)
1024
1100
 
1025
1101
  ### Fixed / Added — v0.15.0 session feedback
1026
1102
  - **`state` file `path` resolution.** A RELATIVE `path` (save/load/export) used to
@@ -1232,7 +1308,7 @@ discoverable rename table).
1232
1308
  searches only the widest form. The suppression matches on offset-overlap AND
1233
1309
  pointer-value (so two coincidentally co-located but distinct pointers are never
1234
1310
  falsely merged). The other 12 platforms emit a single width, so this is a
1235
- verified no-op there. (NBA-Jam-TE agent nit: 20 hits → the 10 distinct
1311
+ verified no-op there. (sports-title agent nit: 20 hits → the 10 distinct
1236
1312
  relocation handles, no hand-dedupe.)
1237
1313
  - **`cpu({op:'call'})` watchdog now trips on a wrong-entry free-run, not just a
1238
1314
  tight loop — on EVERY CPU.** Two cross-system gaps fixed:
@@ -1249,7 +1325,7 @@ discoverable rename table).
1249
1325
  just `m68k_run`** — so it actually fires on **SMS/GG** (where the Z80 is the
1250
1326
  active CPU). Before, `callSubroutine` armed a watchdog that could never trip
1251
1327
  on SMS/GG (the counter only incremented on the m68k), so a Z80 free-run fell
1252
- to `maxFrames`. Requires the rebuilt `romdev-core-gpgx` WASM. (NBA-Jam-TE
1328
+ to `maxFrames`. Requires the rebuilt `romdev-core-gpgx` WASM. (sports-title
1253
1329
  agent nit, generalized to all 14 platforms.)
1254
1330
 
1255
1331
  ## 0.10.0
@@ -1327,7 +1403,7 @@ Requires the bumped core packages.
1327
1403
 
1328
1404
  ## 0.7.0
1329
1405
 
1330
- Reverse-engineering follow-ups from the NBA Jam (Genesis) agent's decompress
1406
+ Reverse-engineering follow-ups from a Genesis sports-title agent's decompress
1331
1407
  feedback. Genesis reference for the hang-fix (the watchdog is a core hook — it
1332
1408
  fans out to every core in 0.8.0); the JS-layer fixes (watchDma, previewTileArt)
1333
1409
  are all-platform.
@@ -1566,7 +1642,7 @@ sprite-inspection bug fix.
1566
1642
  ## 0.3.0
1567
1643
 
1568
1644
  The reverse-engineering / romhacking release — a full RE toolkit driven by a real
1569
- NBA Jam Tournament Edition (Genesis) session, plus PC Engine + MSX cheat coverage
1645
+ a Genesis sports-title session, plus PC Engine + MSX cheat coverage
1570
1646
  and a cleaner package split.
1571
1647
 
1572
1648
  ### Added — RE / romhacking toolkit
package/README.md CHANGED
@@ -1,12 +1,20 @@
1
1
  # romdev
2
2
 
3
- The entry point for **romdev** — vibe-code real retro games. Lets a coding agent build, run, and inspect actual homebrew ROMs (NES, SNES, Game Boy, Genesis, Atari, C64, GBA, and more) with one command.
3
+ The entry point for **romdev** — vibe-code real retro games. Build, run, inspect, and reverse-engineer actual homebrew ROMs (NES, SNES, Game Boy, Genesis, Atari, C64, GBA, and more) with one command — drive it yourself or let a coding assistant do it.
4
4
 
5
5
  ```bash
6
6
  npx romdevtools
7
7
  ```
8
8
 
9
- That's it — one command starts the local romdev **tool server** (no global install, no host compiler/emulator). Point any coding agent at it three ways:
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).
@@ -31,6 +39,7 @@ This package contains all the JavaScript — the tool surface, the WASM emulator
31
39
  - Cores: `romdev-core-{fceumm,gambatte,gpgx,vice,handy,prosystem,geargrafx,bluemsx}`
32
40
  - Platforms: `romdev-platform-{snes,gba,atari2600}`
33
41
  - Toolchains: `romdev-toolchain-{cc65,sdcc,m68k-gcc,vasm,rgbds}`
42
+ - Analysis: `romdev-analysis` (Rizin → WASM: control-flow graphs, cross-references, function detection) and `romdev-analysis-decompiler` (Ghidra's C++ decompiler → WASM + SLEIGH processor specs for all 14 CPUs). Power `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`. Lazy-loaded on first use.
34
43
  - Data: `romdev_game_codes` — the bundled game-code / cheat database (a free labeled RAM/code map for thousands of known ROMs), split out so it can grow independently. Lazy-loaded one platform at a time.
35
44
 
36
45
  `@kmamal/sdl` is used only by `playtest()` / `romdevtools-cli play` (the live window). It ships its native binary via its own install script, which npm skips when romdev is a transitive dep (e.g. under `npx`) — so romdev's `postinstall` fetches it, and `playtest()` also self-heals at runtime if the binary is still missing (downloading the prebuilt before the first window open). Either way, if the binary can't be fetched (offline/locked-down network), the headless server is unaffected — only the live window degrades, and the error tells you the one command to fix it.
@@ -1,6 +1,6 @@
1
1
  /* ── tile_engine.c — GBC starter with a tile map + multiple rooms ──
2
2
  *
3
- * Single-screen-per-room layout (Adventure / Zelda-1 / Sokoban shape).
3
+ * Single-screen-per-room layout (top-down dungeon-crawler / room-puzzle shape).
4
4
  * - 20×18 BG map rendered from a `room[]` array
5
5
  * - Walk a sprite around with the d-pad
6
6
  * - Crossing the screen edge transitions to the next room
@@ -1,6 +1,6 @@
1
1
  /* ── tile_engine.c — GBC starter with a tile map + multiple rooms ──
2
2
  *
3
- * Single-screen-per-room layout (Adventure / Zelda-1 / Sokoban shape).
3
+ * Single-screen-per-room layout (top-down dungeon-crawler / room-puzzle shape).
4
4
  * - 20×18 BG map rendered from a `room[]` array
5
5
  * - Walk a sprite around with the d-pad
6
6
  * - Crossing the screen edge transitions to the next room
@@ -1,6 +1,6 @@
1
1
  /* ── two_plane_parallax.c — Genesis SGDK two-plane parallax scaffold ──
2
2
  *
3
- * The "Uridium / Sonic-feel" starting point: a side-scrolling world that
3
+ * A smooth-scrolling side-scroller starting point: a side-scrolling world that
4
4
  * moves SMOOTHLY because the frame loop does HARDWARE SCROLL ONLY. There
5
5
  * are ZERO tilemap writes inside the loop — the two planes are painted
6
6
  * ONCE at setup, and every frame we just nudge two scroll registers and
@@ -30,11 +30,11 @@
30
30
  * plane — you don't get an independent per-plane size. See the Genesis
31
31
  * MENTAL_MODEL.md "Scrolling, parallax & the feel trap".
32
32
  *
33
- * To go WIDER than 512 px (a true Sonic-size level) you keep this exact
33
+ * To go WIDER than 512 px (a large multi-screen level) you keep this exact
34
34
  * loop and add ONE thing: stream the single offscreen column that's about
35
35
  * to scroll into view each time the camera crosses an 8-px tile boundary
36
36
  * (a circular buffer in the 64-cell plane) — NOT a whole-plane redraw.
37
- * See MENTAL_MODEL.md "How Sonic-style large maps really work".
37
+ * See MENTAL_MODEL.md "How large scrolling maps REALLY work".
38
38
  */
39
39
 
40
40
  #include <genesis.h>
@@ -94,7 +94,7 @@ int main(bool hard) {
94
94
  (void)hard;
95
95
 
96
96
  /* Palettes (BGR, 3 bits/chan). PAL0 = sprite, PAL1 = planes. */
97
- PAL_setColor(0 + 5, 0x008E); /* player orange (Uridium-ish) */
97
+ PAL_setColor(0 + 5, 0x008E); /* player orange */
98
98
  PAL_setColor(16 + 1, 0x0A86); /* ground top (light) */
99
99
  PAL_setColor(16 + 2, 0x0530); /* ground body (dark) */
100
100
  PAL_setColor(16 + 3, 0x0CCC); /* block edge (white) */
@@ -1,6 +1,6 @@
1
1
  /* ── tile_engine.c — NES starter with a tile map + multiple rooms ──
2
2
  *
3
- * Single-screen-per-room layout (Adventure / Zelda-1 / Sokoban shape).
3
+ * Single-screen-per-room layout (top-down dungeon-crawler / room-puzzle shape).
4
4
  * - 32×30 BG nametable rendered from a `room[]` array
5
5
  * - Walk a sprite around with the d-pad
6
6
  * - Crossing the screen edge transitions to the next room
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.30.0",
3
+ "version": "0.40.1",
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",
@@ -50,6 +50,8 @@
50
50
  "fuse.js": "^7.4.1",
51
51
  "omggif": "^1.0.10",
52
52
  "pngjs": "^7.0.0",
53
+ "romdev-analysis": "0.1.0",
54
+ "romdev-analysis-decompiler": "0.1.0",
53
55
  "romdev-core-bluemsx": "0.6.0",
54
56
  "romdev-core-fceumm": "0.10.0",
55
57
  "romdev-core-gambatte": "0.9.0",
@@ -0,0 +1,276 @@
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
+ /** 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 };
195
+ let maps;
196
+ try {
197
+ maps = await runRizinJson({ romBytes, arch, bits, commands: "omlj" });
198
+ } catch { maps = []; }
199
+ for (const m of (Array.isArray(maps) ? maps : [])) {
200
+ const from = m.from ?? m.vaddr ?? 0;
201
+ const to = m.to ?? (from + (m.size ?? 0));
202
+ if (vaddr >= from && vaddr < to) return { paddr: vaddr - (m.delta ?? 0), vbase: m.delta ?? 0 };
203
+ }
204
+ return { paddr: vaddr, vbase: 0 };
205
+ }
206
+
207
+ /**
208
+ * Decompile the function containing `address` to C pseudocode (Ghidra).
209
+ * @returns {{platform, langid, address, code, warnings, qualityNote}}
210
+ */
211
+ export async function analyzeDecompile(romPath, address, platformOverride) {
212
+ if (address == null) throw new Error("analyze decompile: address required");
213
+ const platform = platformOverride ?? sniffPlatform(romPath);
214
+ if (!platform) throw new Error(`analyze decompile: unknown platform for '${path.basename(romPath)}'`);
215
+ if (!SLEIGH_LANGID[platform]) throw new Error(`analyze decompile: unsupported platform '${platform}'`);
216
+ const romBytes = new Uint8Array(await readFile(romPath));
217
+
218
+ // Use rizin's loader mapping to turn the VA (what the user sees from
219
+ // target='functions') into the file offset the raw decompiler image needs.
220
+ // PCE uses the 6502 plugin only for the map/loader (HuC6280 decode is the
221
+ // decompiler's job via SLEIGH) — its flat image bases at 0 either way.
222
+ const arch = RIZIN_ARCH[platform] ?? "6502";
223
+ const bits = { arm: 32, m68k: 32, snes: 16 }[arch];
224
+ const { paddr, vbase } = await vaMapping(romBytes, arch, bits, address, platform);
225
+ if (paddr < 0 || paddr >= romBytes.length) {
226
+ throw new Error(
227
+ `decompile: address ${hx(address)} maps to file offset ${paddr}, outside the ` +
228
+ `${romBytes.length}-byte image for ${platform}.`
229
+ );
230
+ }
231
+ // The raw decompiler loads byte 0 at VMA 0. Code that references absolute CPU
232
+ // addresses (typical 6502: JSR/JMP to $Fxxx) only resolves if the image sits
233
+ // at the right CPU base. Rizin's map gives `vbase` when it knows the base;
234
+ // some headerless carts (2600/7800) it loads at 0, so we supply the base from
235
+ // a per-platform table. Left-pad the image by the base so file offset == CPU
236
+ // address, then decompile at the function's CPU address. Capped at 64KB (the
237
+ // 6502 family's whole address space) so a large base never over-allocates.
238
+ // Atari 2600/7800 are headerless 6502 dumps rizin loads at 0; supply the real
239
+ // CPU base so absolute references ($8000/$C000/$F000) resolve. 7800's base is
240
+ // size-dependent (16KB→$C000, 32KB→$8000); 7800 carts may carry a 128-byte
241
+ // header before the body.
242
+ let forcedBase = 0, bodyStart = 0;
243
+ if (platform === "atari2600") {
244
+ forcedBase = 0xf000;
245
+ } else if (platform === "atari7800") {
246
+ const hasHdr = romBytes.length > 128 &&
247
+ romBytes[1] === 0x41 && romBytes[2] === 0x54; // "AT"
248
+ bodyStart = hasHdr ? 128 : 0;
249
+ const body = romBytes.length - bodyStart;
250
+ forcedBase = body <= 0x4000 ? 0xc000 : body <= 0x8000 ? 0x8000 : 0x4000;
251
+ }
252
+ const base = vbase > 0 ? vbase : forcedBase;
253
+ let image = romBytes, decompAddr = paddr;
254
+ if (base > 0 && base <= 0x10000) {
255
+ const body = romBytes.subarray(bodyStart);
256
+ const padded = new Uint8Array(base + body.length);
257
+ padded.set(body, base);
258
+ image = padded;
259
+ decompAddr = base + (paddr - bodyStart); // CPU address of the function
260
+ }
261
+ const r = await decompileFunction({ platform, romBytes: image, fileOffset: decompAddr });
262
+ const QUALITY = {
263
+ gba: "excellent (ARM)", genesis: "excellent (M68K)",
264
+ gb: "good (SM83)", gbc: "good (SM83)", sms: "good (Z80)", gg: "good (Z80)", msx: "good (Z80)",
265
+ snes: "medium (65816 variable register width)", pce: "medium (HuC6280)",
266
+ nes: "rough (6502 architecture limit)", atari2600: "rough (6502)", atari7800: "rough (6502)",
267
+ c64: "rough (6502)", lynx: "rough (65C02)",
268
+ };
269
+ return {
270
+ platform, langid: r.langid,
271
+ address, addressHex: hx(address),
272
+ code: r.code,
273
+ warnings: r.warnings,
274
+ qualityNote: QUALITY[platform] ?? "unknown",
275
+ };
276
+ }