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.
- package/AGENTS.md +12 -3
- package/CHANGELOG.md +89 -13
- package/README.md +11 -2
- 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 +276 -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 +3 -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,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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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-
|
|
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. (
|
|
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. (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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.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
|
+
}
|