romdevtools 0.30.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -3
- package/CHANGELOG.md +70 -13
- package/README.md +2 -1
- package/examples/gb/templates/tile_engine.c +1 -1
- package/examples/gbc/templates/tile_engine.c +1 -1
- package/examples/genesis/templates/two_plane_parallax.c +4 -4
- package/examples/nes/templates/tile_engine.c +1 -1
- package/package.json +3 -1
- package/src/analysis/analyze.js +263 -0
- package/src/analysis/decompile.js +108 -0
- package/src/analysis/decompiler/sleigh/6502.cspec +34 -0
- package/src/analysis/decompiler/sleigh/6502.ldefs +33 -0
- package/src/analysis/decompiler/sleigh/6502.pspec +16 -0
- package/src/analysis/decompiler/sleigh/6502.sla +3414 -0
- package/src/analysis/decompiler/sleigh/65816-snes.pspec +249 -0
- package/src/analysis/decompiler/sleigh/65816.cspec +17 -0
- package/src/analysis/decompiler/sleigh/65816.ldefs +15 -0
- package/src/analysis/decompiler/sleigh/65816.sla +23670 -0
- package/src/analysis/decompiler/sleigh/65c02.sla +4683 -0
- package/src/analysis/decompiler/sleigh/68000.cspec +67 -0
- package/src/analysis/decompiler/sleigh/68000.ldefs +68 -0
- package/src/analysis/decompiler/sleigh/68000.pspec +9 -0
- package/src/analysis/decompiler/sleigh/68000_register.cspec +118 -0
- package/src/analysis/decompiler/sleigh/68040.sla +81693 -0
- package/src/analysis/decompiler/sleigh/ARM.ldefs +377 -0
- package/src/analysis/decompiler/sleigh/ARM4t_le.sla +53758 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.cspec +209 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.pspec +40 -0
- package/src/analysis/decompiler/sleigh/ARMt_v45.pspec +43 -0
- package/src/analysis/decompiler/sleigh/HuC6280.cspec +67 -0
- package/src/analysis/decompiler/sleigh/HuC6280.ldefs +18 -0
- package/src/analysis/decompiler/sleigh/HuC6280.pspec +150 -0
- package/src/analysis/decompiler/sleigh/HuC6280.sla +7524 -0
- package/src/analysis/decompiler/sleigh/sm83.cspec +70 -0
- package/src/analysis/decompiler/sleigh/sm83.ldefs +29 -0
- package/src/analysis/decompiler/sleigh/sm83.pspec +19 -0
- package/src/analysis/decompiler/sleigh/sm83.sla +7695 -0
- package/src/analysis/decompiler/sleigh/z80.cspec +122 -0
- package/src/analysis/decompiler/sleigh/z80.ldefs +57 -0
- package/src/analysis/decompiler/sleigh/z80.pspec +43 -0
- package/src/analysis/decompiler/sleigh/z80.sla +20518 -0
- package/src/analysis/decompiler/wasm/decompile.js +2 -0
- package/src/analysis/decompiler/wasm/decompile.wasm +0 -0
- package/src/analysis/rizin.js +129 -0
- package/src/analysis/wasm/rizin.js +6032 -0
- package/src/analysis/wasm/rizin.wasm +0 -0
- package/src/http/routes.js +1 -1
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/cart-parts.js +5 -2
- package/src/mcp/tools/disasm.js +32 -5
- package/src/mcp/tools/font-map.js +3 -3
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/reinject.js +1 -1
- package/src/mcp/tools/symbols.js +10 -4
- package/src/mcp/tools/trace-vram-source.js +1 -1
- package/src/mcp/tools/watch-memory.js +1 -1
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +48 -3
- package/src/platforms/atari2600/MENTAL_MODEL.md +6 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +6 -0
- package/src/platforms/c64/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/lib/c/README.md +1 -1
- package/src/platforms/gba/MENTAL_MODEL.md +7 -1
- package/src/platforms/gbc/MENTAL_MODEL.md +6 -0
- package/src/platforms/gbc/lib/c/README.md +1 -1
- package/src/platforms/genesis/MENTAL_MODEL.md +8 -2
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/genesis/lib/wram.s +1 -1
- package/src/platforms/gg/MENTAL_MODEL.md +6 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/nes/MENTAL_MODEL.md +6 -0
- package/src/platforms/pce/MENTAL_MODEL.md +6 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -0
- package/src/platforms/snes/MENTAL_MODEL.md +10 -4
- package/src/toolchains/_worker/wasm-worker.js +5 -0
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
|
|
112
|
-
|
|
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,67 @@ 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.0 — 2026-06-11
|
|
8
|
+
|
|
9
|
+
### Reverse-engineering analysis engine — control-flow graphs, deep xrefs, function detection, and a decompiler
|
|
10
|
+
|
|
11
|
+
A full open-source RE analysis layer, covering **all 14 platforms** with zero
|
|
12
|
+
proprietary dependencies. Two new binary packages carry the WebAssembly; the
|
|
13
|
+
main package gains four `disasm` targets and one `symbols` op that drive them.
|
|
14
|
+
|
|
15
|
+
- **`disasm({target:'functions'})`** — auto-detected function list
|
|
16
|
+
(`{address, size, nbbs, cc, callers, callees}`): the structural map of an
|
|
17
|
+
unknown ROM, the carve step before you label anything live.
|
|
18
|
+
- **`disasm({target:'cfg', address})`** — basic-block control-flow graph of the
|
|
19
|
+
function at `address` (nodes + typed edges: jump / branch_true / branch_false).
|
|
20
|
+
- **`disasm({target:'xrefs', address})`** — every cross-reference TO `address`,
|
|
21
|
+
following the analysis graph. Deeper than the flat `target:'references'` da65
|
|
22
|
+
operand scan — prefer `xrefs` once a function pass has run, `references` for a
|
|
23
|
+
quick header-less sweep.
|
|
24
|
+
- **`disasm({target:'decompile', address})`** — Ghidra C-like **pseudocode** for
|
|
25
|
+
the function at `address`, with the decompiler's own warnings and a per-CPU
|
|
26
|
+
`qualityNote`. Altitude rule: decompile is for UNDERSTANDING (and as a port
|
|
27
|
+
spec when retargeting to a bigger machine) — `target:'project'` stays the
|
|
28
|
+
byte-exact rebuildable edit path. Quality is excellent on ARM (GBA) and M68K
|
|
29
|
+
(Genesis), good on SM83 (GB/GBC) and Z80 (SMS/GG/MSX), medium on 65816 (SNES)
|
|
30
|
+
and HuC6280 (PC Engine), and rough on the 6502 family (an architecture limit —
|
|
31
|
+
every tool is rough on 6502).
|
|
32
|
+
- **`symbols({op:'analyze'})`** — one-shot structural map of a ROM
|
|
33
|
+
(auto-detected functions + strings + entrypoints), no `.dbg`/`.map` needed.
|
|
34
|
+
|
|
35
|
+
Built from pinned upstreams, fetch-on-demand, never vendored — only the compiled
|
|
36
|
+
artifacts ship (see `scripts/build-rizin.sh`, `scripts/build-decompiler.sh`,
|
|
37
|
+
`scripts/versions.json`):
|
|
38
|
+
- **`romdev-analysis`** — Rizin compiled to WASM (the CFG / xrefs / functions
|
|
39
|
+
engine). LGPL-3.0.
|
|
40
|
+
- **`romdev-analysis-decompiler`** — Ghidra's C++ decompiler compiled to WASM
|
|
41
|
+
(no JVM, no rizin) plus SLEIGH processor tables for all 14 CPUs. Apache-2.0,
|
|
42
|
+
with full per-component attribution in the package NOTICE (Ghidra/NSA, and the
|
|
43
|
+
community SM83 / 65816 / HuC6280 SLEIGH specs).
|
|
44
|
+
|
|
45
|
+
### Documentation: no commercial game titles in shipped source
|
|
46
|
+
|
|
47
|
+
Swept every shipped tool description, doc, README, mental-model guide, and the
|
|
48
|
+
ROM-hacking playbook for commercial game/franchise names and replaced them with
|
|
49
|
+
generic platform + hardware + mechanic descriptions ("a banked NES racer", "a
|
|
50
|
+
top-down dungeon-crawler shape"). Console and chip names are unchanged — they're
|
|
51
|
+
not anyone's IP. The bundled cheat database (third-party crowd-sourced data) is
|
|
52
|
+
unaffected.
|
|
53
|
+
|
|
54
|
+
### Tests build their own ROMs (no external fixtures)
|
|
55
|
+
|
|
56
|
+
Tests that needed a real ROM now build one from our **own example sources** at
|
|
57
|
+
runtime instead of depending on a ROM file on disk — so the whole suite runs
|
|
58
|
+
with no external/commercial ROM anywhere. Every previously-skipped fixture-gated
|
|
59
|
+
test now runs: **918 tests, 918 pass, 0 fail, 0 skipped**. (This also surfaced a
|
|
60
|
+
latent fidelity bug the silent skips had hidden: `wrapRomFromParts` dropped the
|
|
61
|
+
iNES battery-SRAM flag on round-trip — now preserved via a new `hasBattery`
|
|
62
|
+
field, and exposed on the `wrapRomFromParts` tool.)
|
|
63
|
+
|
|
7
64
|
## 0.30.0 — 2026-06-11
|
|
8
65
|
|
|
9
66
|
### RE-tooling round — the Cheat-Engine "locate a value + find its writer" workflow
|
|
10
|
-
From
|
|
67
|
+
From an NES reverse-engineering feedback batch. Six additions to the
|
|
11
68
|
memory/breakpoint primitives:
|
|
12
69
|
- **`memory({op:'searchUnknown'})`** — the unknown-initial-value hunt: seed the
|
|
13
70
|
WHOLE region with no value, then narrow across in-game events with
|
|
@@ -164,7 +221,7 @@ three guarantees now hold across the whole platform matrix:
|
|
|
164
221
|
- `test/regsnap-all-cores.test.js`: live single-step snapshot + freeze proof
|
|
165
222
|
on 10 platforms (plus the existing gpgx suite for Genesis/SMS/GG).
|
|
166
223
|
|
|
167
|
-
### Fixed/Added — gpgx core round (
|
|
224
|
+
### Fixed/Added — gpgx core round (a both-consoles sports-title feedback): break-instant truth on Genesis/SMS/GG
|
|
168
225
|
The first core rebuild in this release (gpgx only; pins unchanged, patch extended).
|
|
169
226
|
- **`registersAtHit` on Genesis/SMS/GG** — `breakpoint({on:'pc'|'write'|'read'})`
|
|
170
227
|
hits now carry the FULL register file (m68k d0-d7/a0-a7/pc/sr/sp; z80
|
|
@@ -333,7 +390,7 @@ of repeated logic errors. The big ones:
|
|
|
333
390
|
respond to input, and each platform's audio was captured and RMS-checked.
|
|
334
391
|
(Atari 2600 has no puzzle genre by design.)
|
|
335
392
|
|
|
336
|
-
### Fixed/Added — the 0.27.0
|
|
393
|
+
### Fixed/Added — the 0.27.0 NES RE feedback round (banked-NES rebuilds, A/B diff, token cuts)
|
|
337
394
|
- **Banked NES `disasm({target:'project'})` now emits COMPLETE, working rebuild
|
|
338
395
|
glue** (the headline ask): a `HEADER` segment with the original 16 iNES bytes,
|
|
339
396
|
a per-bank `PRGn` segment wrapper for every bank, a multi-bank `nes_rebuild.cfg`
|
|
@@ -407,7 +464,7 @@ defaulted to native; this is the docs telling the truth about it.)
|
|
|
407
464
|
## 0.27.0
|
|
408
465
|
|
|
409
466
|
### Added — `breakpoint(on:'pc', captureMemory:[…])` reads named RAM at the hit
|
|
410
|
-
Completes item 2 of
|
|
467
|
+
Completes item 2 of an NES action-game RE report. 0.26.0 shipped `registersAtHit` (the
|
|
411
468
|
break-instant register file) but not the memory half. Now `breakpoint(on:'pc')`
|
|
412
469
|
takes `captureMemory:[{region,offset,length,label}]` and returns those reads inline
|
|
413
470
|
as `capturedMemory`, so register + RAM inspection at a PC collapses into ONE call —
|
|
@@ -418,7 +475,7 @@ hit frame (stable + what RE needs), documented as such.
|
|
|
418
475
|
## 0.26.0
|
|
419
476
|
|
|
420
477
|
### Fixed — NES `breakpoint(on:'pc')` now returns reliable break-instant registers
|
|
421
|
-
An agent RE'ing NES
|
|
478
|
+
An agent RE'ing an NES action game found that after a `pc` breakpoint hit, a follow-up
|
|
422
479
|
`cpu({op:'read'})` returned the **idle-loop PC**, not the breakpoint instruction —
|
|
423
480
|
the documented "break, then read the live register file" workflow gave end-of-frame
|
|
424
481
|
state. Root cause: fceumm drains the cycle budget on hit but `retro_run` still
|
|
@@ -447,7 +504,7 @@ own frame correctly. Null until a ROM is loaded.
|
|
|
447
504
|
## 0.25.0
|
|
448
505
|
|
|
449
506
|
### 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
|
|
507
|
+
Follow-up to the 0.24.0 C64 keyboard work: an agent RE'ing a C64 shoot-'em-up could now
|
|
451
508
|
press keys, but couldn't (a) script a keyboard+joystick startup TIMELINE in one
|
|
452
509
|
call, or (b) tell whether a non-responsive key reached VICE at all. Both added — no
|
|
453
510
|
core rebuild (the `c64_cia1_regs` region + key matrix already existed):
|
|
@@ -520,7 +577,7 @@ or the tool list); full release notes remain in CHANGELOG.md for humans.
|
|
|
520
577
|
## 0.24.0
|
|
521
578
|
|
|
522
579
|
### Added — C64 keyboard + joyport input (VICE core patch)
|
|
523
|
-
An agent RE'ing C64
|
|
580
|
+
An agent RE'ing a C64 shoot-'em-up could reach the intro via joystick but couldn't ENTER
|
|
524
581
|
gameplay — the game needs **F1** (1 player) + fire on **port 2**, and romdev's
|
|
525
582
|
input was joypad-mask-only. Many C64 games gate gameplay behind KEYBOARD setup
|
|
526
583
|
screens that joystick can't reach. The VICE core now exports
|
|
@@ -602,7 +659,7 @@ training agents to ignore lint. Now: provably-VRAM dest → warning, plain RAM a
|
|
|
602
659
|
repeated starfield + player sprite, hardware scroll only, zero loop-time tilemap
|
|
603
660
|
writes. Builds clean, renders, scrolls (verified).
|
|
604
661
|
- Genesis MENTAL_MODEL/TROUBLESHOOTING: "do NOT rewrite tilemaps in the frame
|
|
605
|
-
loop", logical-vs-hardware plane size, the correct parallax loop,
|
|
662
|
+
loop", logical-vs-hardware plane size, the correct parallax loop, large-scroller-style
|
|
606
663
|
column streaming, and a "why does movement feel choppy?" recipe.
|
|
607
664
|
|
|
608
665
|
### Changed — discoverability (the recurring root cause)
|
|
@@ -1020,7 +1077,7 @@ savestate) is now fully supported, with NO new top-level tool:
|
|
|
1020
1077
|
- **Honest "no save":** empty `save_ram` now says *why* — "this cart has no battery
|
|
1021
1078
|
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
1079
|
core source: no core patches were needed; earlier "broken" readings were
|
|
1023
|
-
password-
|
|
1080
|
+
password-based NES carts, which correctly have no battery.)
|
|
1024
1081
|
|
|
1025
1082
|
### Fixed / Added — v0.15.0 session feedback
|
|
1026
1083
|
- **`state` file `path` resolution.** A RELATIVE `path` (save/load/export) used to
|
|
@@ -1232,7 +1289,7 @@ discoverable rename table).
|
|
|
1232
1289
|
searches only the widest form. The suppression matches on offset-overlap AND
|
|
1233
1290
|
pointer-value (so two coincidentally co-located but distinct pointers are never
|
|
1234
1291
|
falsely merged). The other 12 platforms emit a single width, so this is a
|
|
1235
|
-
verified no-op there. (
|
|
1292
|
+
verified no-op there. (sports-title agent nit: 20 hits → the 10 distinct
|
|
1236
1293
|
relocation handles, no hand-dedupe.)
|
|
1237
1294
|
- **`cpu({op:'call'})` watchdog now trips on a wrong-entry free-run, not just a
|
|
1238
1295
|
tight loop — on EVERY CPU.** Two cross-system gaps fixed:
|
|
@@ -1249,7 +1306,7 @@ discoverable rename table).
|
|
|
1249
1306
|
just `m68k_run`** — so it actually fires on **SMS/GG** (where the Z80 is the
|
|
1250
1307
|
active CPU). Before, `callSubroutine` armed a watchdog that could never trip
|
|
1251
1308
|
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. (
|
|
1309
|
+
to `maxFrames`. Requires the rebuilt `romdev-core-gpgx` WASM. (sports-title
|
|
1253
1310
|
agent nit, generalized to all 14 platforms.)
|
|
1254
1311
|
|
|
1255
1312
|
## 0.10.0
|
|
@@ -1327,7 +1384,7 @@ Requires the bumped core packages.
|
|
|
1327
1384
|
|
|
1328
1385
|
## 0.7.0
|
|
1329
1386
|
|
|
1330
|
-
Reverse-engineering follow-ups from
|
|
1387
|
+
Reverse-engineering follow-ups from a Genesis sports-title agent's decompress
|
|
1331
1388
|
feedback. Genesis reference for the hang-fix (the watchdog is a core hook — it
|
|
1332
1389
|
fans out to every core in 0.8.0); the JS-layer fixes (watchDma, previewTileArt)
|
|
1333
1390
|
are all-platform.
|
|
@@ -1566,7 +1623,7 @@ sprite-inspection bug fix.
|
|
|
1566
1623
|
## 0.3.0
|
|
1567
1624
|
|
|
1568
1625
|
The reverse-engineering / romhacking release — a full RE toolkit driven by a real
|
|
1569
|
-
|
|
1626
|
+
a Genesis sports-title session, plus PC Engine + MSX cheat coverage
|
|
1570
1627
|
and a cleaner package split.
|
|
1571
1628
|
|
|
1572
1629
|
### Added — RE / romhacking toolkit
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# romdev
|
|
2
2
|
|
|
3
|
-
The entry point for **romdev** — vibe-code real retro games.
|
|
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
|
|
@@ -31,6 +31,7 @@ This package contains all the JavaScript — the tool surface, the WASM emulator
|
|
|
31
31
|
- Cores: `romdev-core-{fceumm,gambatte,gpgx,vice,handy,prosystem,geargrafx,bluemsx}`
|
|
32
32
|
- Platforms: `romdev-platform-{snes,gba,atari2600}`
|
|
33
33
|
- Toolchains: `romdev-toolchain-{cc65,sdcc,m68k-gcc,vasm,rgbds}`
|
|
34
|
+
- 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
35
|
- 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
36
|
|
|
36
37
|
`@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 (
|
|
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 (
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.40.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",
|
|
@@ -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,263 @@
|
|
|
1
|
+
// analyze.js — MCP-facing RE analysis ops built on the Rizin WASM engine:
|
|
2
|
+
// control-flow graphs, deep cross-references, auto-detected functions, and a
|
|
3
|
+
// one-shot structural map. Complements disasm.js (da65/native, rebuildable
|
|
4
|
+
// output) — rizin gives the GRAPH structure da65 can't.
|
|
5
|
+
//
|
|
6
|
+
// Address model: rizin's own bin-loader sets the load address for formats it
|
|
7
|
+
// recognizes (iNES → 0x8000, GBA → 0x08000000, raw → 0). For platforms whose
|
|
8
|
+
// flat file maps 1:1 to the CPU bus (Genesis, plain binaries) the file offset
|
|
9
|
+
// IS the CPU address. We pass an explicit base only where it helps; the
|
|
10
|
+
// reported addresses are rizin virtual addresses, which match the CPU view for
|
|
11
|
+
// the common (unbanked / first-bank) case. Banked carts: a bank's window is
|
|
12
|
+
// resolved by the existing disasm mappers — analysis here is whole-file.
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { runRizin, runRizinJson, RIZIN_ARCH } from "./rizin.js";
|
|
16
|
+
import { decompileFunction, SLEIGH_LANGID } from "./decompile.js";
|
|
17
|
+
|
|
18
|
+
/** Sniff platform from a ROM extension (mirrors disasm.js). */
|
|
19
|
+
export function sniffPlatform(p) {
|
|
20
|
+
if (/\.nes$/i.test(p)) return "nes";
|
|
21
|
+
if (/\.(sfc|smc)$/i.test(p)) return "snes";
|
|
22
|
+
if (/\.gbc$/i.test(p)) return "gbc";
|
|
23
|
+
if (/\.gb$/i.test(p)) return "gb";
|
|
24
|
+
if (/\.sms$/i.test(p)) return "sms";
|
|
25
|
+
if (/\.gg$/i.test(p)) return "gg";
|
|
26
|
+
if (/\.a26$/i.test(p)) return "atari2600";
|
|
27
|
+
if (/\.a78$/i.test(p)) return "atari7800";
|
|
28
|
+
if (/\.prg$/i.test(p)) return "c64";
|
|
29
|
+
if (/\.(lnx|lyx)$/i.test(p)) return "lynx";
|
|
30
|
+
if (/\.gba$/i.test(p)) return "gba";
|
|
31
|
+
if (/\.pce$/i.test(p)) return "pce";
|
|
32
|
+
if (/\.(gen|md|bin)$/i.test(p)) return "genesis";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** rizin asm.bits per arch (analysis defaults; rizin's loader usually sets
|
|
37
|
+
* these for recognized formats, but raw blobs need a hint). */
|
|
38
|
+
const BITS = { arm: 32, m68k: 32, snes: 16 };
|
|
39
|
+
|
|
40
|
+
/** Build the common rizin invocation context for a ROM + platform. Returns
|
|
41
|
+
* { romBytes, arch, bits, note } — arch null means let rizin sniff. */
|
|
42
|
+
async function loadContext(romPath, platformOverride) {
|
|
43
|
+
const platform = platformOverride ?? sniffPlatform(romPath);
|
|
44
|
+
if (!platform) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`analyze: could not determine platform from '${path.basename(romPath)}' — pass platform explicitly`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!(platform in RIZIN_ARCH)) {
|
|
50
|
+
throw new Error(`analyze: unsupported platform '${platform}'`);
|
|
51
|
+
}
|
|
52
|
+
const arch = RIZIN_ARCH[platform];
|
|
53
|
+
if (arch == null) {
|
|
54
|
+
throw new Error(`analyze: no Rizin arch mapping for platform '${platform}'`);
|
|
55
|
+
}
|
|
56
|
+
const romBytes = new Uint8Array(await readFile(romPath));
|
|
57
|
+
// PCE: rizin's 6502 plugin drives the loader + standard control flow for
|
|
58
|
+
// function detection, but mis-decodes HuC6280 custom opcodes — CFG/xrefs are
|
|
59
|
+
// approximate. Accurate HuC6280 decode is the decompiler's job (SLEIGH spec).
|
|
60
|
+
const approx = platform === "pce";
|
|
61
|
+
return { platform, romBytes, arch, bits: BITS[arch], approx };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Hex-format an address the way agents expect for the platform width. */
|
|
65
|
+
function hx(n) { return "0x" + (n >>> 0).toString(16); }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Auto-detected function list for a ROM.
|
|
69
|
+
* @returns {{platform, count, functions: Array<{address, name, size, nbbs, cc, callers, callees}>}}
|
|
70
|
+
*/
|
|
71
|
+
export async function analyzeFunctions(romPath, platformOverride) {
|
|
72
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
73
|
+
const fns = await runRizinJson({ romBytes, arch, bits, commands: "aaa; aflj" });
|
|
74
|
+
const functions = fns.map((f) => ({
|
|
75
|
+
address: f.offset,
|
|
76
|
+
addressHex: hx(f.offset),
|
|
77
|
+
name: f.name,
|
|
78
|
+
size: f.size,
|
|
79
|
+
nbbs: f.nbbs, // basic-block count
|
|
80
|
+
cc: f.cc, // cyclomatic complexity
|
|
81
|
+
callers: f.indegree ?? (f.codexrefs?.length ?? 0),
|
|
82
|
+
callees: f.outdegree ?? 0,
|
|
83
|
+
}));
|
|
84
|
+
return { platform, arch, count: functions.length, functions };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Control-flow graph for the function containing `address`.
|
|
89
|
+
* @returns {{platform, address, nodes: Array<{id,address,size,instructions,jump,fail,out}>, edges}}
|
|
90
|
+
*/
|
|
91
|
+
export async function analyzeCfg(romPath, address, platformOverride) {
|
|
92
|
+
if (address == null) throw new Error("analyze cfg: address required");
|
|
93
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
94
|
+
// afbj = basic blocks of the function as JSON: each block has addr/size/jump/
|
|
95
|
+
// fail/ninstr. `jump` is the taken edge; `fail` (present only on conditional
|
|
96
|
+
// blocks) is the fall-through. This is the structured CFG source — `agf json`
|
|
97
|
+
// only gives a text body blob with untyped out_nodes.
|
|
98
|
+
const blocks = await runRizinJson({
|
|
99
|
+
romBytes, arch, bits,
|
|
100
|
+
commands: `aaa; af @ ${hx(address)}; afbj @ ${hx(address)}`,
|
|
101
|
+
});
|
|
102
|
+
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
103
|
+
return { platform, arch, address, addressHex: hx(address), nodes: [], edges: [], note: "no function/blocks at address" };
|
|
104
|
+
}
|
|
105
|
+
const nodes = blocks.map((b) => ({
|
|
106
|
+
id: b.addr,
|
|
107
|
+
address: b.addr,
|
|
108
|
+
addressHex: hx(b.addr),
|
|
109
|
+
size: b.size,
|
|
110
|
+
ninstr: b.ninstr,
|
|
111
|
+
}));
|
|
112
|
+
const edges = [];
|
|
113
|
+
for (const b of blocks) {
|
|
114
|
+
const conditional = b.fail != null;
|
|
115
|
+
if (b.jump != null) edges.push({ from: b.addr, to: b.jump, type: conditional ? "branch_true" : "jump_or_fall" });
|
|
116
|
+
if (b.fail != null) edges.push({ from: b.addr, to: b.fail, type: "branch_false" });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
platform, arch,
|
|
120
|
+
address, addressHex: hx(address),
|
|
121
|
+
blockCount: nodes.length,
|
|
122
|
+
nodes, edges,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* All cross-references TO `address` across the ROM.
|
|
128
|
+
* @returns {{platform, address, count, refs: Array<{from, to, type}>}}
|
|
129
|
+
*/
|
|
130
|
+
export async function analyzeXrefs(romPath, address, platformOverride) {
|
|
131
|
+
if (address == null) throw new Error("analyze xrefs: address required");
|
|
132
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
133
|
+
let refs;
|
|
134
|
+
try {
|
|
135
|
+
refs = await runRizinJson({ romBytes, arch, bits, commands: `aaa; axtj @ ${hx(address)}` });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// axtj prints nothing (not even `[]`) when there are zero refs → our JSON
|
|
138
|
+
// guard throws. Treat "no JSON" as "no refs".
|
|
139
|
+
if (/no JSON/.test(e.message)) refs = [];
|
|
140
|
+
else throw e;
|
|
141
|
+
}
|
|
142
|
+
const out = (refs ?? []).map((r) => ({
|
|
143
|
+
from: r.from,
|
|
144
|
+
fromHex: hx(r.from),
|
|
145
|
+
to: r.to,
|
|
146
|
+
type: (r.type || "").toLowerCase(), // CALL / CODE / DATA / STRING
|
|
147
|
+
opcode: r.opcode,
|
|
148
|
+
}));
|
|
149
|
+
return { platform, arch, address, addressHex: hx(address), count: out.length, refs: out };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One-shot structural map: functions + strings + entrypoints from a full
|
|
154
|
+
* analysis pass. The "give me the shape of this ROM" call.
|
|
155
|
+
*/
|
|
156
|
+
export async function analyzeStructure(romPath, platformOverride) {
|
|
157
|
+
const { platform, romBytes, arch, bits } = await loadContext(romPath, platformOverride);
|
|
158
|
+
const [fns, strings, entries] = await Promise.all([
|
|
159
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; aflj" }).catch(() => []),
|
|
160
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; izj" }).catch(() => []),
|
|
161
|
+
runRizinJson({ romBytes, arch, bits, commands: "aaa; iej" }).catch(() => []),
|
|
162
|
+
]);
|
|
163
|
+
return {
|
|
164
|
+
platform, arch,
|
|
165
|
+
functionCount: Array.isArray(fns) ? fns.length : 0,
|
|
166
|
+
stringCount: Array.isArray(strings) ? strings.length : 0,
|
|
167
|
+
entrypoints: (Array.isArray(entries) ? entries : []).map((e) => ({ address: e.vaddr, addressHex: hx(e.vaddr) })),
|
|
168
|
+
functions: (Array.isArray(fns) ? fns : []).slice(0, 512).map((f) => ({
|
|
169
|
+
address: f.offset, addressHex: hx(f.offset), name: f.name, size: f.size, callers: f.indegree ?? 0,
|
|
170
|
+
})),
|
|
171
|
+
strings: (Array.isArray(strings) ? strings : []).slice(0, 256).map((s) => ({
|
|
172
|
+
address: s.vaddr, addressHex: hx(s.vaddr), value: s.string,
|
|
173
|
+
})),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Look up rizin's IO map (omlj) for the segment containing `vaddr`. Each map
|
|
178
|
+
* entry carries {from (vaddr base), delta (vaddr-paddr), to}. Returns
|
|
179
|
+
* { paddr, vbase } where paddr = vaddr-delta is the raw-file offset and vbase
|
|
180
|
+
* (= delta) is the address byte 0 of the file maps to on the CPU bus. */
|
|
181
|
+
async function vaMapping(romBytes, arch, bits, vaddr) {
|
|
182
|
+
let maps;
|
|
183
|
+
try {
|
|
184
|
+
maps = await runRizinJson({ romBytes, arch, bits, commands: "omlj" });
|
|
185
|
+
} catch { maps = []; }
|
|
186
|
+
for (const m of (Array.isArray(maps) ? maps : [])) {
|
|
187
|
+
const from = m.from ?? m.vaddr ?? 0;
|
|
188
|
+
const to = m.to ?? (from + (m.size ?? 0));
|
|
189
|
+
if (vaddr >= from && vaddr < to) return { paddr: vaddr - (m.delta ?? 0), vbase: m.delta ?? 0 };
|
|
190
|
+
}
|
|
191
|
+
return { paddr: vaddr, vbase: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decompile the function containing `address` to C pseudocode (Ghidra).
|
|
196
|
+
* @returns {{platform, langid, address, code, warnings, qualityNote}}
|
|
197
|
+
*/
|
|
198
|
+
export async function analyzeDecompile(romPath, address, platformOverride) {
|
|
199
|
+
if (address == null) throw new Error("analyze decompile: address required");
|
|
200
|
+
const platform = platformOverride ?? sniffPlatform(romPath);
|
|
201
|
+
if (!platform) throw new Error(`analyze decompile: unknown platform for '${path.basename(romPath)}'`);
|
|
202
|
+
if (!SLEIGH_LANGID[platform]) throw new Error(`analyze decompile: unsupported platform '${platform}'`);
|
|
203
|
+
const romBytes = new Uint8Array(await readFile(romPath));
|
|
204
|
+
|
|
205
|
+
// Use rizin's loader mapping to turn the VA (what the user sees from
|
|
206
|
+
// target='functions') into the file offset the raw decompiler image needs.
|
|
207
|
+
// PCE uses the 6502 plugin only for the map/loader (HuC6280 decode is the
|
|
208
|
+
// decompiler's job via SLEIGH) — its flat image bases at 0 either way.
|
|
209
|
+
const arch = RIZIN_ARCH[platform] ?? "6502";
|
|
210
|
+
const bits = { arm: 32, m68k: 32, snes: 16 }[arch];
|
|
211
|
+
const { paddr, vbase } = await vaMapping(romBytes, arch, bits, address);
|
|
212
|
+
if (paddr < 0 || paddr >= romBytes.length) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`decompile: address ${hx(address)} maps to file offset ${paddr}, outside the ` +
|
|
215
|
+
`${romBytes.length}-byte image for ${platform}.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
// The raw decompiler loads byte 0 at VMA 0. Code that references absolute CPU
|
|
219
|
+
// addresses (typical 6502: JSR/JMP to $Fxxx) only resolves if the image sits
|
|
220
|
+
// at the right CPU base. Rizin's map gives `vbase` when it knows the base;
|
|
221
|
+
// some headerless carts (2600/7800) it loads at 0, so we supply the base from
|
|
222
|
+
// a per-platform table. Left-pad the image by the base so file offset == CPU
|
|
223
|
+
// address, then decompile at the function's CPU address. Capped at 64KB (the
|
|
224
|
+
// 6502 family's whole address space) so a large base never over-allocates.
|
|
225
|
+
// Atari 2600/7800 are headerless 6502 dumps rizin loads at 0; supply the real
|
|
226
|
+
// CPU base so absolute references ($8000/$C000/$F000) resolve. 7800's base is
|
|
227
|
+
// size-dependent (16KB→$C000, 32KB→$8000); 7800 carts may carry a 128-byte
|
|
228
|
+
// header before the body.
|
|
229
|
+
let forcedBase = 0, bodyStart = 0;
|
|
230
|
+
if (platform === "atari2600") {
|
|
231
|
+
forcedBase = 0xf000;
|
|
232
|
+
} else if (platform === "atari7800") {
|
|
233
|
+
const hasHdr = romBytes.length > 128 &&
|
|
234
|
+
romBytes[1] === 0x41 && romBytes[2] === 0x54; // "AT"
|
|
235
|
+
bodyStart = hasHdr ? 128 : 0;
|
|
236
|
+
const body = romBytes.length - bodyStart;
|
|
237
|
+
forcedBase = body <= 0x4000 ? 0xc000 : body <= 0x8000 ? 0x8000 : 0x4000;
|
|
238
|
+
}
|
|
239
|
+
const base = vbase > 0 ? vbase : forcedBase;
|
|
240
|
+
let image = romBytes, decompAddr = paddr;
|
|
241
|
+
if (base > 0 && base <= 0x10000) {
|
|
242
|
+
const body = romBytes.subarray(bodyStart);
|
|
243
|
+
const padded = new Uint8Array(base + body.length);
|
|
244
|
+
padded.set(body, base);
|
|
245
|
+
image = padded;
|
|
246
|
+
decompAddr = base + (paddr - bodyStart); // CPU address of the function
|
|
247
|
+
}
|
|
248
|
+
const r = await decompileFunction({ platform, romBytes: image, fileOffset: decompAddr });
|
|
249
|
+
const QUALITY = {
|
|
250
|
+
gba: "excellent (ARM)", genesis: "excellent (M68K)",
|
|
251
|
+
gb: "good (SM83)", gbc: "good (SM83)", sms: "good (Z80)", gg: "good (Z80)", msx: "good (Z80)",
|
|
252
|
+
snes: "medium (65816 variable register width)", pce: "medium (HuC6280)",
|
|
253
|
+
nes: "rough (6502 architecture limit)", atari2600: "rough (6502)", atari7800: "rough (6502)",
|
|
254
|
+
c64: "rough (6502)", lynx: "rough (65C02)",
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
platform, langid: r.langid,
|
|
258
|
+
address, addressHex: hx(address),
|
|
259
|
+
code: r.code,
|
|
260
|
+
warnings: r.warnings,
|
|
261
|
+
qualityNote: QUALITY[platform] ?? "unknown",
|
|
262
|
+
};
|
|
263
|
+
}
|