From ROM Workbench
Reverse-engineer Williams Pinball Controller (WPC) ROMs — static + byte-level analysis with the bank-aware rom.py tool (6809 dis, recursive-descent xref/funcs, dump/search/strings) coupled to the live CPU debugger (replay.py --interactive + dbg.py — breakpoints, watchpoints, single-step, a frozen-CPU REPL). Use to disassemble a region, find who calls/references an address, trace where a register or RAM byte comes from, set a breakpoint and step, resolve a banked PC, verify patch bytes, or identify what a switch number physically is.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rom-workbench:debugThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **Orientation:** if you haven't already, load `rom-workbench:overview` for the
Orientation: if you haven't already, load
rom-workbench:overviewfor the end-to-end mod workflow (setup → record → synthesize → debug → build → test) and where this step fits.
Reverse-engineering a WPC ROM is one loop: simulate → break/step → read the disassembly at the live PC. Both halves ship here:
rom.py) — self-contained, stdlib-only, bank-aware: dis
(from-scratch 6809 disassembly), xref/funcs (recursive-descent
cross-reference + function-start discovery), dump/search/strings/info.replay.py --interactive + dbg.py drive the patched
libpinmame Debug API against a recorded session: breakpoints, watchpoints,
single-step, a persistent frozen-CPU REPL.The two are coupled in code: the live debugger's dis imports rom.py so it
decodes the actual instruction stream at the live PC. The debugger host is the
same replay.py you use in the record skill — it lives there because the
replay substrate (sessions + NVRAM snapshots) is what the debugger runs on.
The CPU is a Motorola 68B09E (6809), 2 MHz. ROM is banked: $8000-$FFFF is
the always-mapped system region; $4000-$7FFF is a 16 KB window into one of
~30 ROM pages selected by WPC_ROM_BANK ($3FFC).
PulseSw n) rom.py (static, bank-aware — primary) live debugger (replay.py/dbg.py)
┌───────────────────────────────────┐ ┌──────────────────────────────┐
│ dis — disassemble a region │ loc │ replay.py --interactive + │
│ xref — who calls/references $X │ ◄─────── │ dbg.py: frozen CPU, regs, │
│ funcs — function starts │ ground- │ mem, step, watchpoints, │
│ dump/search/strings — bytes │ truth │ the call chain (@S unwind) │
└───────────────────────────────────┘ └──────────────────────────────┘
▲ │
└────────── feed live `loc` into ──────────┘
rom.py xref/funcs answer the static "who/where" questions; the live debugger
answers the dynamic "what actually happened / which page" questions static
analysis can't. Use them together — establish ground truth live (which page a PC
is in, runtime register/RAM values, the call chain), then feed that loc
straight into rom.py dis/xref.
One-time install handled by the setup skill: Visual Pinball X + our patched
libpinmame (with the Debug API, sets PINMAME_DIR) + VPinMAME COM (regsvr32,
needs Admin once). If anything is missing, replay.py prints a clear "run the
setup skill first" message.
A register snapshot (PC/S/U/X/Y/A/B/CC/DP) is not enough to locate code: a
PC in $4000-$7FFF is ambiguous until you know which page is mapped. The live
ROM bank (WPC_ROM_BANK @ $3FFC) is shadowed in RAM at (DP<<8)+0x11
(usually $0011; DP=0 in WPC system + most game code).
Every dbg hit reports bank (read from (DP<<8)+0x11) and loc
($<PC>@p<bank> for banked PCs, $<PC> for system). Paste loc straight into
rom.py dis / rom.py xref.
Set breakpoints/watchpoints up front, replay from POST, analyse the trace after. Good for "catch every hit of X over a whole boot" and watchpoint sweeps.
# Break before each listed PC; single-step N after each hit; dump memory windows.
python3 ${CLAUDE_PLUGIN_ROOT}/bin/replay.py --rom congo_21 `
--rom-zip .\dist\congo_21_modded.zip --session .\sessions\<utc> `
--nvram .\dist\congo_21_modded.nv --trace dbg `
--break-pc 0x403F --dbg-step-after 30 --dbg-mem '@S:2,@X:16,0x0011'
# Find every writer/reader of a RAM slot.
python3 ${CLAUDE_PLUGIN_ROOT}/bin/replay.py ... --trace dbg --watch-w '0x1670' --dbg-mem '0x0011'
--dbg-mem windows are read via PinmameReadMainCPUByte while the CPU is
frozen. Forms: fixed 0xADDR[:LEN] or register-relative @REG[+/-OFF][:LEN]
(REG ∈ pc,s,u,x,y), resolved from that hit's registers. Highest-value uses:
@S:2 → top-of-stack = the return address → who called this routine.@X:16 / @U:16 → dump the struct/string a pointer points at.@S:48 → unwind the call chain (scan for $4xxx/$8xxx words).Boots once, holds the CPU frozen, and serves commands over a socket so the next probe is decided from what the last one showed — no re-boot per probe, state survives between commands. This is the big lever for iterative work.
# Launch in the background; wait for "[dbg] paused at <loc>".
python3 ${CLAUDE_PLUGIN_ROOT}/bin/replay.py --rom congo_21 `
--rom-zip .\dist\congo_21_modded.zip --session .\sessions\<utc> `
--nvram .\dist\congo_21_modded.nv --interactive --break-pc 0x4037
# Then drive it (each call = one command; the emulator stays paused):
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py regs
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py dis @pc 12
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py mem @u 24
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py step 20
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py continue until 0x4067
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py wp add w 0x1670
python3 ${CLAUDE_PLUGIN_ROOT}/bin/dbg.py quit
Commands: regs | mem <addr> [len] | dis [addr] [n] | step [n] | continue [until <pc>] | bp add|del <pc> | bp list | wp add r|w <addr> | wp del <addr> | bank | quit. Address forms anywhere: 0xNNNN, $NNNN,
NNNN(hex), or register-relative @X @S+2 @U-1 (resolved from the frozen regs).
rom.py (no emulator)Self-contained, stdlib-only, bank-aware. Reads ROM bytes directly — fast,
faithful. Feed it the loc from any live breakpoint.
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py info # ROM size, version byte, checksum, RESET vec
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py dump '$FFEC' 16 # system-ROM address
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py dump '$4C0E@p37' 32 # banked: page $37, addr $4C0E
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py dump 0x7FFEC 16 # raw file offset
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py search "BD 90 C4" # byte sequence (JSR $90C4)
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py search '"Copyright"' # ASCII string
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py strings 6 --section sys # printable ASCII runs ≥ 6 chars
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py dis '$403F@p39' 40 # 6809 disassembly (n bytes)
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py xref '$43A6@p39' # who calls/jumps to an address
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py xref '$1670' --data # +LD/ST data references
python3 ${CLAUDE_PLUGIN_ROOT}/bin/rom.py funcs --page 39 # discovered function starts
Without --rom, auto-detects orig/*.zip in the working directory; otherwise
pass --rom <path>.
dis — from-scratch 6809 disassemblerrom.py dis '$ADDR@pPAGE' [nbytes] decodes the WPC CPU instruction stream one
instruction at a time, so banked code stays in its page (logical $4000-$7FFF
addresses don't bleed across pages), resolves branch/JSR targets, and annotates
them with the page. Go-to for a quick, paste-the-loc-from-a-breakpoint listing.
loc from a dbg/interactive regs for ground-truth, correctly
paged instructions. It's the same decoder the live interactive session uses
(it imports disasm_one from this file).xref / funcs — recursive-descent cross-referencerom.py xref '$ADDR@pPAGE' lists every instruction that calls/jumps/branches to
an address; --data adds extended LD/ST references. rom.py funcs [--page PP]
lists discovered function starts. Both run a bank-aware recursive-descent
disassembly from seeds (PSHS/PSHU prologues + CPU vectors), so they read real
instructions rather than grepping bytes:
$4000-$7FFF targets are scoped to the source's page (the only page
mapped while it runs). $8000+ targets are global (any page can reference).funcs reports only validated call targets (high precision); raw prologue
bytes that land in data are used as seeds but not reported as functions.Limits: (1) cross-page calls route through the WPC OS bank dispatcher (system
code jumping to $4xxx with the page chosen at runtime) and can't be statically
attributed to a page — use the live debugger's stack unwind (@S:2) for those.
(2) --data catches extended operands (LDA $4E6D), not immediate pointer
loads (LDY #$450F). (3) A function reachable only via dispatch — never an
intra-page call or a prologue — may be missed.
WPC ROM layout (any size): system ROM is always the last 32 KiB at $8000–$FFFF.
The banked-page numbering shifts by total size:
| ROM size | Banked pages |
|---|---|
| 128 KiB | $38–$3D (6 pages) |
| 256 KiB | $34–$3D (14 pages) |
| 512 KiB | $20–$3D (30 pages) |
| 1 MiB | $00–$3D (62 pages) |
Formula: file_offset(page, addr) = (page - firstPage) × 0x4000 + (addr - 0x4000).
rom.py accepts| Format | Meaning | Example |
|---|---|---|
$NNNN | System ROM ($8000–$FFFF) | $FFEE, $8DB3 |
$NNNN@pXX | Banked page XX | $4C0E@p37 |
0xNNNNN | Raw file offset | 0x7FFEE |
A return address in $4000-$7FFF recovered from the stack is page-ambiguous,
and — the trap — its page is usually not the page of the routine you unwound
it from. Cross-page calls go through a bank-switch gate (the $8A04/$8A07
family, $86FC, …): a tiny system stub that sets WPC_ROM_BANK (STA $3FFC)
and returns with PULS CC,A,B,PC. The gate's return frame holds both the
caller PC and the caller's ROM bank as adjacent saved bytes — so when it
pops PC it simultaneously restores the bank. To attribute the return address to
a page, read that restored-bank byte; don't assume it shares the current page.
Recipe (worked example — the routine that loads Congo's version digits):
--break-pc 0x4037, A/B live), dump the gate frame:
dbg.py mem @s 16. Disassemble the gate (rom.py dis '$8A07' 12) to learn
its PULS layout, then map the frame bytes onto it.$8A07's PULS CC,A,B,PC: the pulled PC = caller return; the byte
the gate reloads into $11/$3FFC = caller bank. Here that gave caller
$42C6 with bank 0x3A → $42C6@p3A, not @p39.rom.py funcs --page <bank> to find the enclosing function, then dis it.(This is exactly the bug that left notes/congo-version-display.md chasing
$42C6@p39 — wrong page — for a whole session. Always read the gate's bank byte.)
When a step or disassembly shows a switch read, or a table's VBScript shows
vpmTimer.PulseSw n, you need to know what switch N physically is. Three
sources, in order of authority for the switch you care about:
The PinMAME driver source — ROM ground truth for the switches the driver
models (start, trough, slings, jets, lanes, coin door): the game's
src/wpc/*.c in the PinMAME tree (for Congo, prelim/congo.c). It does
not include most playfield targets — the prelim sim doesn't model them.
The table VBScript (orig/<table>.vbs) — maps physical playfield objects
to the switch numbers the ROM reads (Controller.Switch(n) /
vpmTimer.PulseSw n), so it covers the targets the driver omits. Extract it
from the table once:
# macOS / Linux
VPinballX_GL --extractvbs orig/<table>.vpx # writes orig/<table>.vbs
# Windows
VPinballX.exe -ExtractVBS orig\<table>.vpx
Then grep the .vbs for Controller.Switch( / PulseSw to read the wiring.
Empirical, from a real recording — the definitive answer to "which switch
does X". Replay a session that exercised the feature with --watch-w on the
RAM the feature touches, and read the switch edge that immediately precedes
each effect (this is how the Congo TRAVI-COM/satellite targets were pinned:
--watch-w 0x068F, the two scoring hits preceded by sw52 and sw51).
(This is the same recipe the synthetic-record skill uses to author sessions
by switch name — there it's name→number to drive the ROM, here it's number→
meaning to interpret what the CPU is reading.)
| Question | Reach for |
|---|---|
| "what runs during X / what are the regs at PC Y" | live debugger (A or B) |
| "let me poke around from here" / iterative bisection | interactive session (B) |
| "who calls / references $X" (all paths, incl. unexecuted) | rom.py xref |
| "disassemble this region" | rom.py dis (live loc → static) |
| "what writes this RAM byte (executed paths)" | --watch-w (A) |
| "what's at this address / find this string" | rom.py dump/search/strings |
| "what is switch N physically" | driver source → .vbs → empirical --watch-w |
For "I observed behaviour X, where does it come from?":
rom.py strings/search to find the hook (a format string, a
known constant). rom.py xref on it to find the code that references it.rom.py dis '<loc>' on the confirmed loc to see what the
routine does and where it loads its inputs.--watch-w <addr> finds
every writer; if from a register, follow it up the call chain (@S:2 unwind).
rom.py xref finds the static callers to cross-check.rom.py dump/dis before flipping bytes via
the build skill.PinmameReadMainCPUByte does not apply the WPC
bank to $4000-$7FFF. The interactive dis/mem work around it by reading
the ROM image at page=bank (ROM doesn't self-modify, so it's faithful).
RAM/IO (<$4000) and system ROM ($8000+) read fine live.dis <addr> follows the current bank. Right for @pc; to decode a
different page use static rom.py dis '$addr@pPAGE' with the page spelled out.
(Bitten decoding $4155 while the CPU sat in a page-3C sub-call → got data.)(U+offset) lives
on its frame; watchpointing that address is mostly stack noise. Trace the
value to its register origin, not the stack slot.rom.py dis + the live debugger. WPC bank-switching
means $4000-$7FFF overlays are page-specific; static tools that don't model
this decode garbage. Always supply @pPAGE and confirm against the live debugger.PinMAME is a MAME-0.76-era core; its built-in debugger is a legacy TUI, not
scriptable. The whole path is a Python driver + the patched libpinmame DLL in
one process; new observability is added by exporting from libpinmame (the
patched switch-recorder source; prebuilt DLLs ship in lib/). The
bank-resolution, --dbg-mem, and interactive session needed no new DLL
exports — just the existing PinmameDebug* + PinmameReadMainCPUByte.
Implementation: bin/replay_host.py.
${CLAUDE_PLUGIN_ROOT}/
├── skills/debug/SKILL.md # this file
├── bin/
│ ├── rom.py # bank-aware static tool (dis/xref/funcs/dump/search/strings)
│ ├── replay.py # debugger host — --interactive holds the CPU frozen
│ ├── dbg.py # thin client for the --interactive debugger socket
│ └── replay_host.py # libpinmame ctypes driver (imports rom.py for live dis)
└── lib/ # prebuilt patched libpinmame (Debug API)
switch-recorder branch off
github.com/vpinball/pinmame (src/libpinmame/libpinmame.{h,cpp}); prebuilt
DLLs ship in lib/. See the record skill's References for the rebuild path.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub techbert08/rom-workbench --plugin rom-workbench