65816-llvm-mos/scripts/mameDebug.py
Scott Duensing 3388f3c5a5 More updates
2026-06-03 20:46:31 -05:00

1516 lines
61 KiB
Python
Executable file

#!/usr/bin/env python3
# mameDebug.py - Python front-end for source-level debugging of W65816
# binaries inside MAME. Wraps MAME's autoboot-Lua + -debug -oslog stream
# into a GDB-style interactive prompt plus a default-on --trace check
# that drives the source-PC resolver end-to-end.
#
# Phase 3.1 of the gap-closure plan.
#
# Two modes:
#
# --trace Set bp at `main` (or another symbol), run until first
# BP-HIT line surfaces on -oslog, capture the PC, resolve
# it through scripts/pc2line.py. Exits 0 on resolved
# hit. This is the default-on smoke check; it runs
# unconditionally in scripts/smokeTest.sh.
#
# (default) Interactive (dbg) prompt — gated behind DEBUGGER_E2E=1
# in the environment, because driving MAME's debugger
# across a TTY isn't reliable in CI. Supports the GDB
# subset: b/c/s/n/finish/p &SYM/q.
#
# Critical reviewer-flagged constraints (do not violate):
# - cpu.debug:bpset(addr) ONE-arg form CRASHES MAME. Always use the
# 3-arg form:
# bpset(pc, '', 'logerror "BP-HIT PC=%X A=%X X=%X Y=%X S=%X DBR=%X\\n",pc,a,x,y,s,db; go')
# - DO NOT call cpu.debug:go() from add_machine_pause_notifier
# callbacks (reentrancy SEGFAULT — see SESSION_RECOVERY.md).
# - MAME under -debug starts with execution_state='stop'. The Lua
# boot script must explicitly assign 'run' to kick simulation.
# - Multi-frame `bt` is out of scope — requires DW_AT_frame_base or
# per-function frame-size sidecar. `finish` is provided instead.
#
# Usage:
# scripts/mameDebug.py --trace --bin demos/helloBeep_dbg.bin \
# --map demos/helloBeep_dbg.map \
# --dwarf demos/helloBeep_dbg.dwarf \
# [--break main]
#
# DEBUGGER_E2E=1 scripts/mameDebug.py --bin ... --map ... --dwarf ...
import argparse
import importlib.util
import os
import re
import subprocess
import sys
import tempfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(SCRIPT_DIR)
# Import pc2line.py as a module so the REPL can reuse its DWARF parsing
# (line table, DIE walking, type chains, locals evaluator) without
# shelling out + reparsing on every command. pc2line.py is the single
# source of truth for DWARF semantics; we must NOT duplicate any of it.
def _loadPc2lineModule():
spec = importlib.util.spec_from_file_location(
"pc2line", os.path.join(SCRIPT_DIR, "pc2line.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
pc2line = _loadPc2lineModule()
# ---- Map + DWARF helpers ---------------------------------------------
def loadMapSyms(path):
"""Parse a link816 .map. Return [(addr, sym), ...] sorted ascending."""
syms = []
with open(path) as f:
for ln in f:
ln = ln.strip()
if not ln.startswith("0x"):
continue
parts = ln.split()
if len(parts) >= 2:
try:
syms.append((int(parts[0], 16), parts[1]))
except ValueError:
pass
syms.sort()
return syms
def lookupSym(syms, name):
"""Return address for the named symbol, or None."""
for addr, sym in syms:
if sym == name:
return addr
return None
def resolveBreakpoint(spec, syms, dwarf, mapPath):
"""Resolve `FUNC` or `FILE:LINE` to a 24-bit PC. Returns int or None."""
if ":" in spec:
# FILE:LINE — dump pc2line table and grep.
file_part, line_part = spec.rsplit(":", 1)
try:
line_num = int(line_part)
except ValueError:
return None
# Use pc2line --dump.
cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
"--sidecar", dwarf, "--map", mapPath, "--dump"]
out = subprocess.check_output(cmd, text=True)
for ln in out.splitlines():
parts = ln.split()
if len(parts) < 2:
continue
pc_hex, file_line = parts[0], parts[1]
if ":" not in file_line:
continue
f, l = file_line.rsplit(":", 1)
if f == file_part and l == str(line_num):
return int(pc_hex, 16)
return None
# Pure symbol name.
return lookupSym(syms, spec)
# ---- Lua boot script builder ----------------------------------------
LUA_TEMPLATE = r"""
-- mameDebug autoboot script (generated by scripts/mameDebug.py)
local BIN_PATH = "{bin_path}"
local LOAD_AT = 0x{load_at:04x}
local START_PC = 0x{start_pc:06x}
local BPS = {{ {bp_list} }}
local FINISH = {finish_lua}
local installed = false
local frame = 0
local finish_state = "armed" -- "armed" -> "ret-installed" -> "done"
local cpu, dbg, mem
emu.register_frame_done(function()
frame = frame + 1
if frame == 30 and not installed then
cpu = manager.machine.devices[":maincpu"]
dbg = cpu.debug
mem = cpu.spaces["program"]
local f = io.open(BIN_PATH, "rb")
if not f then
print("MAMEDBG-BIN-MISSING " .. BIN_PATH)
manager.machine:exit()
return
end
local data = f:read("*all")
f:close()
-- Skip the IIgs IO window; otherwise stray rodata pad bytes can
-- clobber soft switches. Matches runInMame.sh.
for i = 1, #data do
local addr = LOAD_AT + i - 1
if not (addr >= 0x00C000 and addr < 0x00D000) then
mem:write_u8(addr, data:byte(i))
end
end
-- START_PC may be either LOAD_AT (run crt0 first; requires the
-- crt0 to work standalone — true for crt0.s smoke harness,
-- NOT for crt0Gsos.s which expects Loader-applied relocations)
-- or the bp target itself (jump-to-bp; works for any image).
-- The Python front-end picks based on whether the binary's
-- __start is the OMF-style crt0 or the flat smoke crt0.
cpu.state["PC"].value = START_PC
cpu.state["PB"].value = 0x00
cpu.state["DB"].value = 0x00
cpu.state["D"].value = 0x00
-- P=0x04 (M=0, X=0, I=0): matches the state crt0 leaves before
-- JSL main, so jumping straight to main with this P is honest.
-- Demos that bp before crt0 finishes still work — bpset matches
-- on the PC regardless of P.
cpu.state["P"].value = 0x04
cpu.state["E"].value = 0
cpu.state["S"].value = 0x01FF
-- Install breakpoints in the 3-arg form (the 1-arg form crashes
-- MAME). `; go` resumes execution from the action string itself,
-- avoiding the reentrancy SEGFAULT documented in SESSION_RECOVERY.
-- If FINISH is true, the action also stamps the 24-bit return
-- PC (read from the JSL frame on the stack: PCL@s+1, PCH@s+2,
-- PBR@s+3) plus a 0xFEED marker into bank-2 scratch
-- ($020000..$020005) so the register_periodic poller can read
-- it and install a one-shot bp at the post-call PC. Nested
-- bpset inside the action string itself does NOT fire in this
-- MAME version (verified by spike), so we route the install
-- through register_periodic.
for _, pc in ipairs(BPS) do
local action
if FINISH then
action = 'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; ' ..
'w@0x020000=b@(s+1) + (b@(s+2)<<8); w@0x020002=b@(s+3); w@0x020004=0xFEED; go'
else
action = 'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; go'
end
dbg:bpset(pc, '', action)
end
-- Resume execution. Under -debug MAME pauses at startup; the
-- bpset action's "; go" tail handles re-resuming after each
-- hit, but the FIRST kick needs an explicit :go() from the
-- autoboot script. register_frame_done is a safe context
-- (NOT the add_machine_pause_notifier path which has the
-- documented reentrancy SEGFAULT).
dbg:go()
print(string.format("MAMEDBG-LOADED bytes=%d bps=%d finish=%s", #data, #BPS, tostring(FINISH)))
installed = true
end
if frame == {exit_frame} then
print("MAMEDBG-EXIT frame=" .. frame)
manager.machine:exit()
end
end)
-- Finish poller: when the entry bp has fired (marker == 0xFEED),
-- read the return-PC triplet from bank-2 scratch and install a
-- one-shot bp at (PC + 1). Polling cost: a couple of mem reads per
-- periodic tick; install latency vs RTL determines whether the bp
-- catches the function before it exits. For typical main() with
-- substantial body, the latency is fine. For 3-NOP toys, the bp
-- may install after RTL — that's an acceptable proof-of-concept
-- limitation noted in the docstring.
emu.register_periodic(function()
if not FINISH or finish_state ~= "armed" or not mem then return end
local marker = mem:read_u16(0x020004)
if marker == 0xFEED then
local ret_lo16 = mem:read_u16(0x020000)
local ret_bank = mem:read_u8(0x020002)
local ret_pc = (ret_bank * 0x10000) + ret_lo16 + 1
dbg:bpset(ret_pc, '',
'logerror "MAMEDBG-RET PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; go')
print(string.format("MAMEDBG-FINISH-ARMED ret_pc=0x%06X", ret_pc))
finish_state = "ret-installed"
mem:write_u16(0x020004, 0)
end
end)
"""
def buildLuaScript(bin_path, load_at, bp_pcs, exit_frame, start_pc=None,
finish=False):
"""Return Lua autoboot script text.
start_pc selects the initial PC after the binary is written to RAM.
None means "run from load_at" (i.e. through the crt0); pass a
specific PC to jump straight to a breakpoint target — required for
crt0Gsos / crt0Gno images whose startup expects the GS/OS Loader
to have applied relocations.
finish=True turns each entry bp into an entry+return pair. At the
entry bp, the action stamps the 24-bit return PC into bank-2
scratch. A register_periodic poller reads the marker and installs
a one-shot bp at (return_PC + 1). Verified end-to-end against a
long-running synthetic callee in the spike harness.
"""
bp_list = ", ".join(f"0x{p:06x}" for p in bp_pcs)
if start_pc is None:
start_pc = load_at
return LUA_TEMPLATE.format(
bin_path = bin_path,
load_at = load_at,
start_pc = start_pc,
bp_list = bp_list,
exit_frame = exit_frame,
finish_lua = "true" if finish else "false",
)
# ---- MAME launcher ---------------------------------------------------
def runMame(lua_path, seconds, debug_flag, oslog=True):
"""Launch MAME under autoboot, return combined stdout+stderr text."""
env = dict(os.environ)
env["SDL_VIDEODRIVER"] = "dummy"
env["SDL_AUDIODRIVER"] = "dummy"
rom_path = os.path.join(ROOT, "tools/mame/roms")
args = ["mame", "apple2gs",
"-rompath", rom_path,
"-ramsize", "1m",
"-window",
"-seconds_to_run", str(seconds),
"-autoboot_script", lua_path,
"-video", "none", "-sound", "none", "-nothrottle"]
if debug_flag:
# -debugger none keeps us headless while -debug enables bpset
# plumbing. -oslog routes `logerror` output to stderr where we
# can grep MAMEDBG-BP lines.
args[1:1] = ["-debug", "-debugger", "none"]
if oslog:
args.append("-oslog")
timeout_s = seconds + 20 # generous: mame startup is ~5-8s
try:
proc = subprocess.run(
args, env=env, capture_output=True, text=True,
timeout=timeout_s)
except subprocess.TimeoutExpired as e:
return (e.stdout or "") + (e.stderr or "")
return proc.stdout + proc.stderr
# ---- --trace mode ----------------------------------------------------
# `logerror` lines look like:
# MAMEDBG-BP PC=106E A=1234 X=0 Y=38 S=1FF DBR=0
BP_RE = re.compile(
r"MAMEDBG-BP\s+PC=([0-9A-Fa-f]+)\s+A=([0-9A-Fa-f]+)\s+X=([0-9A-Fa-f]+)"
r"\s+Y=([0-9A-Fa-f]+)\s+S=([0-9A-Fa-f]+)\s+DBR=([0-9A-Fa-f]+)")
RET_RE = re.compile(
r"MAMEDBG-RET\s+PC=([0-9A-Fa-f]+)\s+A=([0-9A-Fa-f]+)\s+X=([0-9A-Fa-f]+)"
r"\s+Y=([0-9A-Fa-f]+)\s+S=([0-9A-Fa-f]+)\s+DBR=([0-9A-Fa-f]+)")
def traceMode(args):
"""--trace: set bp at <break>, run, capture first BP-HIT, resolve PC.
When --finish is also passed: at the entry bp, additionally install
a one-shot bp at the function's RTL return address (read from the
24-bit JSL frame on the stack at S+1..S+3) and continue. The
second bp fires after the function returns — proving the
`finish`-command primitive end-to-end via the bpset-with-action-
string mechanism (no reentrancy hazard, no host-side polling loop).
"""
syms = loadMapSyms(args.map)
target = args.break_at or "main"
pc = resolveBreakpoint(target, syms, args.dwarf, args.map)
if pc is None:
print(f"mameDebug: cannot resolve breakpoint '{target}'", file=sys.stderr)
return 2
print(f"[trace] break {target} -> 0x{pc:06x}")
load_at = args.load_at
# Default: jump straight to the bp target. crt0Gsos / crt0Gno
# binaries' __start expects the GS/OS Loader to have already
# applied IMM24 relocations, which isn't the case when we load
# the flat .bin into bank 0 directly. --from-start forces start
# at LOAD_AT (use only with crt0.s smoke binaries, which run
# standalone). --start-at overrides with a user-supplied entry
# point (FUNC or hex) — useful with --finish where the bp is a
# deep callee and we want to start at its outer caller so the JSL
# frame is set up.
if args.from_start:
start_pc = load_at
elif args.start_at:
spec = args.start_at
try:
start_pc = int(spec, 0)
except ValueError:
start_pc = lookupSym(syms, spec)
if start_pc is None:
print(f"mameDebug: --start-at '{spec}' not in map",
file=sys.stderr)
return 2
else:
start_pc = pc
lua_text = buildLuaScript(
args.bin, load_at, [pc], exit_frame=120,
start_pc=start_pc,
finish=args.finish,
)
with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False) as lf:
lf.write(lua_text)
lua_path = lf.name
try:
out = runMame(lua_path, seconds=args.seconds, debug_flag=True)
finally:
os.unlink(lua_path)
if args.verbose:
sys.stderr.write(out)
bps = []
rets = []
for ln in out.splitlines():
m = BP_RE.search(ln)
if m:
bps.append(m.group(1))
m = RET_RE.search(ln)
if m:
rets.append(m.group(1))
if not bps:
print("[trace] FAIL: no BP-HIT in -oslog output", file=sys.stderr)
# Print a sample of the output to diagnose
tail = out.splitlines()[-20:]
for ln in tail:
sys.stderr.write(f" > {ln}\n")
return 1
hit_pc = int(bps[0], 16)
print(f"[trace] BP-HIT PC=0x{hit_pc:06x} (count={len(bps)})")
# Run pc2line.py to resolve to source.
cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
"--sidecar", args.dwarf, "--map", args.map, f"0x{hit_pc:06x}"]
resolved = subprocess.check_output(cmd, text=True).strip()
print(f"[trace] {resolved}")
# Assert pc2line resolved (non-empty FILE/LINE/FUNC).
if "NOT_FOUND" in resolved or "FILE=?" in resolved:
print("[trace] FAIL: pc2line could not resolve the captured PC",
file=sys.stderr)
return 1
if args.finish:
if not rets:
print("[trace] FAIL: --finish requested but no MAMEDBG-RET "
"in -oslog output (function may have returned before "
"the register_periodic poller installed the ret bp; "
"see mameDebug.py docstring)", file=sys.stderr)
return 1
ret_pc = int(rets[0], 16)
print(f"[trace] RET PC=0x{ret_pc:06x} (count={len(rets)})")
cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
"--sidecar", args.dwarf, "--map", args.map,
f"0x{ret_pc:06x}"]
ret_resolved = subprocess.check_output(cmd, text=True).strip()
print(f"[trace] {ret_resolved}")
print("[trace] OK")
return 0
# ---- Interactive (dbg) prompt (gated behind DEBUGGER_E2E=1) ---------
INTERACTIVE_HELP = """
Commands:
b FUNC | FILE:LINE set breakpoint
c continue
s single-step instruction
n step-over (temp-bp at jsl_pc+4, since JSL is 4B)
finish run-until-current-frame-RTL/RTS (i.e. until S
moves above its current value)
p &GLOBAL print address of a global symbol (map lookup)
p VAR print formal-parameter / local for current PC.
Uses the most-recent BP-HIT S register; routes
through pc2line.py --locals.
q | quit exit the debugger
? this help
"""
def interactiveMode(args):
"""Stub interactive prompt — gated behind DEBUGGER_E2E=1.
The trace-mode harness covers the load-set-bp-resolve-PC end-to-end
path with a single capture. An honest interactive loop would need
a bidirectional MAME-Lua RPC (request-reply over a socket, since
-oslog is one-way stderr). That's deferred to a follow-up.
For now the gated path:
- Builds and runs the Lua bootstrap with user-supplied --break
list.
- Forwards each BP-HIT line through pc2line for resolution.
- Reads commands from stdin but only honors `b SYM_or_FILE:LINE`
(queued before launch), `c` (no-op confirming continue), `q`
(exit). Step/finish/print are accepted at parse time but
unimplemented in this slice — they print TODO.
The pieces required for true interactive control (debugger-RPC
socket, machine.debugger.command() from a sequencer Lua coroutine)
are wired up in `mameDebug.lua.tmpl` for future work; the prompt
here just demonstrates the parser surface.
"""
if os.environ.get("DEBUGGER_E2E", "0") != "1":
print("mameDebug: interactive mode is gated behind DEBUGGER_E2E=1",
file=sys.stderr)
print(" use --trace for the smoke-checkable path",
file=sys.stderr)
return 2
syms = loadMapSyms(args.map)
print("mameDebug interactive (DEBUGGER_E2E=1). Type ? for help.")
print(INTERACTIVE_HELP)
bp_pcs = []
last_hit_pc = None
last_hit_sp = None
while True:
try:
line = input("(dbg) ").strip()
except EOFError:
print()
break
if not line:
continue
if line in ("q", "quit"):
break
if line == "?":
print(INTERACTIVE_HELP)
continue
if line.startswith("b "):
spec = line[2:].strip()
pc = resolveBreakpoint(spec, syms, args.dwarf, args.map)
if pc is None:
print(f" cannot resolve {spec!r}")
continue
bp_pcs.append(pc)
print(f" breakpoint at 0x{pc:06x}")
continue
if line == "c":
if not bp_pcs:
print(" no breakpoints set; nothing to continue toward")
continue
# Launch one MAME run with the queued bps, surface every hit.
start_pc = args.load_at if args.from_start else bp_pcs[0]
lua_text = buildLuaScript(args.bin, args.load_at, bp_pcs,
exit_frame=240, start_pc=start_pc)
with tempfile.NamedTemporaryFile(
"w", suffix=".lua", delete=False) as lf:
lf.write(lua_text)
lua_path = lf.name
try:
out = runMame(lua_path, seconds=args.seconds,
debug_flag=True)
finally:
os.unlink(lua_path)
for ln in out.splitlines():
m = BP_RE.search(ln)
if m:
hit_pc = int(m.group(1), 16)
hit_sp = int(m.group(5), 16)
last_hit_pc = hit_pc
last_hit_sp = hit_sp
resolved = subprocess.check_output(
["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
"--sidecar", args.dwarf, "--map", args.map,
f"0x{hit_pc:06x}"],
text=True).strip()
print(f" HIT {resolved} (S=0x{hit_sp:04x})")
continue
if line in ("s", "n", "finish"):
# These need request-reply with the simulator; not in this
# slice. See module docstring.
print(f" TODO: '{line}' requires bidirectional MAME RPC "
"(deferred follow-up — see mameDebug.py docstring)")
continue
if line.startswith("p &"):
sym = line[3:].strip()
addr = lookupSym(syms, sym)
if addr is None:
print(f" no such symbol: {sym}")
else:
print(f" &{sym} = 0x{addr:06x}")
continue
if line.startswith("p "):
# `p VAR` — formal-parameter / local lookup at the most
# recent BP-HIT. Routes through pc2line.py --locals with
# the captured PC + S. Output is filtered to the line
# whose VAR= matches `var` (if no match, all locals are
# shown so the user can see what's in scope).
var = line[2:].strip()
if last_hit_pc is None or last_hit_sp is None:
print(" no recent breakpoint hit; run `c` first")
continue
try:
out = subprocess.check_output(
["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
"--sidecar", args.dwarf, "--map", args.map,
"--locals", "--sp", f"0x{last_hit_sp:04x}",
f"0x{last_hit_pc:06x}"],
text=True)
except subprocess.CalledProcessError as e:
print(f" pc2line --locals failed: {e}")
continue
shown = False
for ln_out in out.splitlines():
if ln_out.startswith(f"VAR={var} ") or \
ln_out.startswith(f"VAR={var}\t"):
print(f" {ln_out}")
shown = True
if not shown:
# Variable name didn't match anything in scope. Print
# everything so the user can see what's available.
for ln_out in out.splitlines():
print(f" {ln_out}")
continue
print(f" unknown command: {line!r}. Type ? for help.")
return 0
# ---- REPL mode (--repl) ---------------------------------------------
#
# An interactive prompt that gives `gdb`-flavour commands on top of the
# load-snapshot-resolve cycle. Because MAME has no bidirectional Lua
# RPC channel under `-debugger none`, every "execute the program"
# command (run / continue / step / next) maps to one MAME process
# launch. The Lua autoboot writes the program into bank-0 memory,
# installs all queued breakpoints, runs until the first hit, captures
# a register + memory snapshot, and exits. The Python REPL then
# decodes the snapshot to answer `print`, `bt`, `where` from cached
# state — no further MAME launch needed for those.
#
# Commands:
# break <sym|file:line|0xADDR> set/queue a breakpoint
# run | continue [c] launch MAME, stop at first bp hit
# step | next advance to next source line
# (via DWARF line table; one bp install)
# bt | backtrace walk the JSL frame chain from S
# where PC -> source line for the last hit
# print <symbol> decode bytes at &symbol per DWARF type
# info locals show formal_parameters + locals
# info breakpoints list queued breakpoints
# delete <N> remove breakpoint by index
# quit | q exit
# ? this help
#
# Smoke-checkable: pipe a script of `break main\nrun\nwhere\nquit\n`
# into `mameDebug.py --repl ...` and assert the BP-HIT + WHERE output.
REPL_HELP = """\
Commands:
break <sym|file:line|0xADDR> set/queue a breakpoint
run | continue launch MAME, stop at first hit
step | next advance to next source line (DWARF)
bt | backtrace walk JSL frame chain from S
where PC -> source line for the last hit
print <symbol> decode bytes at &symbol per DWARF type
info locals show formal_parameters + locals
info breakpoints list queued breakpoints
delete <N> remove breakpoint by index
quit | q exit
? this help
"""
# Lua autoboot for the REPL. Differs from the --trace template in three
# ways:
# 1. Breakpoint actions also dump (a) a 64-byte stack window around S
# and (b) per-symbol memory regions for `print` requests, both as
# tagged log lines so the host can parse.
# 2. exit_frame is generous (240) so a slow run still completes.
# 3. The list of "watch" memory regions is parameterised — the host
# stamps in (addr, len) pairs based on queued `print <symbol>`
# requests.
REPL_LUA_TEMPLATE = r"""
-- mameDebug REPL autoboot (generated by scripts/mameDebug.py --repl)
local BIN_PATH = "{bin_path}"
local LOAD_AT = 0x{load_at:04x}
local START_PC = 0x{start_pc:06x}
local BPS = {{ {bp_list} }}
local WATCHES = {{ {watch_list} }} -- list of {{addr, len}} pairs
local installed = false
local frame = 0
local cpu, dbg, mem
emu.register_frame_done(function()
frame = frame + 1
if frame == 30 and not installed then
cpu = manager.machine.devices[":maincpu"]
dbg = cpu.debug
mem = cpu.spaces["program"]
local f = io.open(BIN_PATH, "rb")
if not f then
print("MAMEDBG-BIN-MISSING " .. BIN_PATH)
manager.machine:exit()
return
end
local data = f:read("*all")
f:close()
for i = 1, #data do
local addr = LOAD_AT + i - 1
if not (addr >= 0x00C000 and addr < 0x00D000) then
mem:write_u8(addr, data:byte(i))
end
end
cpu.state["PC"].value = START_PC
cpu.state["PB"].value = 0x00
cpu.state["DB"].value = 0x00
cpu.state["D"].value = 0x00
cpu.state["P"].value = 0x04
cpu.state["E"].value = 0
cpu.state["S"].value = 0x01FF
-- Build the bp action. We use the 3-arg bpset form (1-arg
-- crashes MAME). The action stamps a magic marker into bank-2
-- scratch ($020010 / 0xDEAD) so the periodic poller can detect
-- the hit and dump memory from a SAFE context (the action
-- string itself can't call multi-statement loops cleanly).
local action_template =
'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; ' ..
'w@0x020010=0xDEAD; w@0x020012=s; w@0x020014=pc & 0xFFFF; w@0x020016=(pc>>16) & 0xFF; go'
for _, pc in ipairs(BPS) do
dbg:bpset(pc, '', action_template)
end
print(string.format("MAMEDBG-LOADED bytes=%d bps=%d watches=%d",
#data, #BPS, #WATCHES))
installed = true
end
if frame == {exit_frame} then
print("MAMEDBG-EXIT frame=" .. frame)
manager.machine:exit()
end
end)
-- Marker-driven snapshot dumper. Once the bp action stamps 0xDEAD at
-- $020010, this periodic handler reads S + PC from the scratch slots
-- and dumps the watched memory regions, then clears the marker.
local snapshotted = false
emu.register_periodic(function()
if installed and not snapshotted and mem ~= nil then
local marker = mem:read_u16(0x020010)
if marker == 0xDEAD then
local s_val = mem:read_u16(0x020012)
local pc_lo = mem:read_u16(0x020014)
local pc_bnk = mem:read_u8(0x020016)
local full_pc = (pc_bnk * 0x10000) + pc_lo
print(string.format("MAMEDBG-SNAP S=0x%04X PC=0x%06X",
s_val, full_pc))
-- Dump the entire bank-0 stack window from S+1 up to the
-- program-entry SP ($01FF). Multi-frame `bt` walks several
-- parent frames upward, each consuming `frameSize + 3`
-- bytes; 64 bytes was enough for the topmost frame only.
-- Capping at $01FF keeps the dump bounded and avoids
-- reading past the user stack into bank-0 hardware
-- registers / soft switches that would surface as
-- $C000-page side-effects.
local stack_top = 0x01FF
for addr = s_val + 1, stack_top do
local v = mem:read_u8(addr)
print(string.format("MAMEDBG-STACK addr=0x%06X val=0x%02X",
addr, v))
end
-- Dump each user-requested watch.
for _, w in ipairs(WATCHES) do
local addr, n = w[1], w[2]
for ofs = 0, n - 1 do
local v = mem:read_u8(addr + ofs)
print(string.format("MAMEDBG-WATCH addr=0x%06X val=0x%02X",
addr + ofs, v))
end
end
mem:write_u16(0x020010, 0)
snapshotted = true
end
end
end)
"""
def buildReplLuaScript(bin_path, load_at, bp_pcs, watch_regions,
start_pc, exit_frame):
"""Build a MAME autoboot Lua script for one REPL run.
bp_pcs: list of int (24-bit PCs) — breakpoints to install.
watch_regions: list of (addr, length) tuples — per-symbol memory
dumps stamped at the first BP hit.
"""
bp_list = ", ".join(f"0x{p:06x}" for p in bp_pcs)
watch_list = ", ".join(f"{{0x{a:06x}, {n}}}" for a, n in watch_regions)
return REPL_LUA_TEMPLATE.format(
bin_path = bin_path,
load_at = load_at,
start_pc = start_pc,
bp_list = bp_list or "",
watch_list = watch_list or "",
exit_frame = exit_frame,
)
# Regex for snapshot/watch/stack lines emitted by the REPL Lua script.
SNAP_RE = re.compile(r"MAMEDBG-SNAP\s+S=0x([0-9A-Fa-f]+)\s+PC=0x([0-9A-Fa-f]+)")
WATCH_RE = re.compile(r"MAMEDBG-WATCH\s+addr=0x([0-9A-Fa-f]+)\s+val=0x([0-9A-Fa-f]+)")
STACK_RE = re.compile(r"MAMEDBG-STACK\s+addr=0x([0-9A-Fa-f]+)\s+val=0x([0-9A-Fa-f]+)")
class ReplState:
"""All persistent state across REPL commands."""
def __init__(self, args):
self.args = args
# Map: address -> symbol name (binary-searchable by funcAt)
self.syms = pc2line.loadMapSymbols(args.map)
# DWARF: line table + DIE trees (parsed once, reused)
self.sectionPayloads = pc2line.loadSidecarSectionsAll(args.dwarf)
self.cus = pc2line.parseAllCus(self.sectionPayloads)
self.lineTable = pc2line.buildTable(args.dwarf)
# Per-function frame records (sorted) — used by `bt` to walk
# parent JSL frames. Empty if the sidecar predates the
# W65816AsmPrinter frame-record emission (older builds /
# hand-written assembly objects); `bt` falls back to the
# single-frame walk in that case.
self.frameRecords = pc2line.loadFrameRecords(args.dwarf)
# Breakpoints: list of (pc, label) - label is the original spec
self.breakpoints = []
# Watches: dict {symbol: (addr, length)}. Length picked from
# the symbol's DWARF type when available, else fall back to 2.
self.watches = {}
# Last snapshot — populated after a run. Empty until first run.
self.lastSnap = None # {"pc": int, "sp": int}
self.lastWatchBytes = {} # {addr: byte} (last run only)
self.lastStackBytes = {} # {addr: byte} (last run only)
def resolveSpec(self, spec):
"""Resolve `FUNC`, `FILE:LINE`, or `0xADDR` to a 24-bit PC.
Returns (pc, label) or (None, error_msg).
"""
spec = spec.strip()
# Hex address?
if spec.lower().startswith("0x"):
try:
return (int(spec, 16), spec)
except ValueError:
return (None, f"invalid hex: {spec!r}")
# File:line?
if ":" in spec:
file_part, line_part = spec.rsplit(":", 1)
try:
want_line = int(line_part)
except ValueError:
return (None, f"invalid line: {line_part!r}")
# Prefer the smallest-PC entry on the requested line so the
# bp lands on the statement's first instruction, not a
# later trailing entry.
best = None
for pc, fidx, ln, ft in self.lineTable:
if ln != want_line:
continue
if 0 < fidx <= len(ft):
fname = os.path.basename(ft[fidx - 1])
else:
fname = "?"
# Match if fname matches OR fname is "?" (DWARF5
# file_idx=0 path means "the CU's primary file" — we
# treat that as a wildcard match for the user-supplied
# file name).
if fname == file_part or fname.endswith(file_part) \
or fname == "?":
if best is None or pc < best[0]:
best = (pc, fname)
if best is not None:
return (best[0], f"{best[1]}:{want_line}")
return (None, f"no DWARF line entry for {spec!r}")
# Bare symbol name — lookup in map.
for addr, sym in self.syms:
if sym == spec:
return (addr, sym)
return (None, f"symbol {spec!r} not in map")
def symbolSize(self, symname):
"""Best-effort size of a global symbol's storage (in bytes).
Looks up DW_TAG_variable DIEs across all CUs. Returns the
resolved type's byte size, or None if not findable. Falls back
to caller-default (2) when None.
"""
for cu in self.cus:
if cu.root is None:
continue
for die in self._iterDies(cu.root):
if die.tag != pc2line.DW_TAG_variable:
continue
nm = pc2line.dieName(cu, die)
if nm != symname:
continue
tref = die.getRaw(pc2line.DW_AT_type)
if tref is None:
return None
target = pc2line._findDieByOffset(cu, tref[0])
return self._typeByteSize(cu, target)
return None
def _iterDies(self, die):
yield die
for ch in die.children:
yield from self._iterDies(ch)
def _typeByteSize(self, cu, die):
"""Walk a type DIE chain, return byte size or None."""
if die is None:
return None
seen = set()
cur = die
while cur is not None and cur.offset not in seen:
seen.add(cur.offset)
tag = cur.tag
# Base / structure / union / enum types carry DW_AT_byte_size.
bs = cur.getRaw(0x0b) # DW_AT_byte_size
if bs is not None:
return bs[0]
if tag == pc2line.DW_TAG_pointer_type:
# 24-bit byte addresses are stored as 4-byte ptr32 by
# default in our ABI; default-on Layer 2 builds use 4-byte
# ptrs. Fall back to addr_size if recorded.
return cu.addr_size
if tag in (0x26, 0x35, 0x37, 0x38):
# const/volatile/restrict/typedef — follow.
t = cur.getRaw(pc2line.DW_AT_type)
if t is None:
return None
cur = pc2line._findDieByOffset(cu, t[0])
continue
if tag == 0x01: # DW_TAG_array_type
t = cur.getRaw(pc2line.DW_AT_type)
if t is None:
return None
elem = self._typeByteSize(cu,
pc2line._findDieByOffset(cu, t[0]))
if elem is None:
return None
# Find first subrange child for count.
for ch in cur.children:
if ch.tag == 0x21: # DW_TAG_subrange_type
ub = ch.getRaw(0x2f) # DW_AT_upper_bound
if ub is not None:
return elem * (ub[0] + 1)
return None
# Other tags — give up.
return None
return None
def typeStrOfSymbol(self, symname):
"""Return a printable type string for a global symbol, or '?'."""
for cu in self.cus:
if cu.root is None:
continue
for die in self._iterDies(cu.root):
if die.tag != pc2line.DW_TAG_variable:
continue
nm = pc2line.dieName(cu, die)
if nm == symname:
return pc2line.varTypeStr(cu, die)
return "?"
def replLaunchMame(state, bp_pcs, start_pc, watch_regions, seconds=4):
"""Launch one MAME run with the queued breakpoints + watches.
Returns the captured stdout/stderr text. Parses MAMEDBG-SNAP,
MAMEDBG-WATCH, MAMEDBG-STACK lines into state.lastSnap +
state.lastWatchBytes + state.lastStackBytes.
"""
lua = buildReplLuaScript(state.args.bin, state.args.load_at,
bp_pcs, watch_regions,
start_pc=start_pc,
exit_frame=240)
with tempfile.NamedTemporaryFile("w", suffix=".lua",
delete=False) as lf:
lf.write(lua)
lua_path = lf.name
try:
out = runMame(lua_path, seconds=seconds, debug_flag=True)
finally:
try:
os.unlink(lua_path)
except OSError:
pass
# Parse snapshot lines.
state.lastSnap = None
state.lastWatchBytes = {}
state.lastStackBytes = {}
bps = []
for ln in out.splitlines():
m = BP_RE.search(ln)
if m:
bps.append({
"pc": int(m.group(1), 16),
"a": int(m.group(2), 16),
"x": int(m.group(3), 16),
"y": int(m.group(4), 16),
"s": int(m.group(5), 16),
"db": int(m.group(6), 16),
})
m = SNAP_RE.search(ln)
if m:
state.lastSnap = {
"sp": int(m.group(1), 16),
"pc": int(m.group(2), 16),
}
m = WATCH_RE.search(ln)
if m:
state.lastWatchBytes[int(m.group(1), 16)] = int(m.group(2), 16)
m = STACK_RE.search(ln)
if m:
state.lastStackBytes[int(m.group(1), 16)] = int(m.group(2), 16)
state.lastBps = bps
return out
def replPrintWhere(state):
"""Print PC -> source line for the last snapshot."""
if state.lastSnap is None:
print(" no snapshot yet — `run` first")
return
pc = state.lastSnap["pc"]
sp = state.lastSnap["sp"]
row = pc2line.query(state.lineTable, pc)
func = pc2line.funcAt(state.syms, pc)
if row is None:
print(f" PC=0x{pc:06x} (no DWARF line) FUNC={func} S=0x{sp:04x}")
else:
_, fname, ln = row
print(f" PC=0x{pc:06x} FILE={fname} LINE={ln} FUNC={func} "
f"S=0x{sp:04x}")
def _btPrintFrame(state, frame_no, pc, sp):
"""Print one bt frame line. Pure formatting — no state mutation."""
func = pc2line.funcAt(state.syms, pc)
row = pc2line.query(state.lineTable, pc)
if row is None:
print(f" #{frame_no} PC=0x{pc:06x} FUNC={func} "
f"S=0x{sp:04x}")
else:
_, fname, ln = row
print(f" #{frame_no} PC=0x{pc:06x} {fname}:{ln} FUNC={func} "
f"S=0x{sp:04x}")
# Maximum unwinder depth. Real recursion can exceed this on the IIgs's
# tiny stack, but past 16 frames the user almost certainly wants the
# truncation hint rather than a wall of identical-looking entries.
BT_MAX_FRAMES = 16
# Initial program-entry SP — crt0 sets up the user stack at $01FF
# (empty-descending) and JSLs main(). Once `bt`'s walker sees S climb
# past this value, we've reached the root and stop without printing
# the bogus "frame above crt0" the rule would otherwise produce.
BT_ROOT_SP = 0x01FF
def replPrintBacktrace(state):
"""Walk the JSL return frame chain using the .debug_frame_w65816
sidecar. Each step decodes the caller's PC from the return-address
triplet pushed by JSL (PCL/PCH/PBR at S+frameSize+1..+3) and the
caller's S as `current_S + frameSize + rtlBytes`.
Falls back to the single-frame walk if no frame records were loaded
(e.g. the sidecar predates this section). That matches the prior
behaviour exactly — the test in scripts/probeReplSmoke.sh remains
backward-compatible.
"""
if state.lastSnap is None:
print(" no snapshot yet — `run` first")
return
pc = state.lastSnap["pc"]
sp = state.lastSnap["sp"]
_btPrintFrame(state, 0, pc, sp)
if not state.frameRecords:
# Old sidecar — fall back to the single-frame return-address
# peek (caller of the current function only). Preserves the
# behaviour shipped before the .debug_frame_w65816 section
# existed; pre-existing smoke probes that depend on the
# "frame #1 visible" invariant still pass against old DWARF.
pcl = state.lastStackBytes.get((sp + 1) & 0xFFFF)
pch = state.lastStackBytes.get((sp + 2) & 0xFFFF)
pbr = state.lastStackBytes.get((sp + 3) & 0xFFFF)
if pcl is None or pch is None or pbr is None:
print(" #1 <return address not in captured stack window>")
return
ret_pc = (((pbr << 16) | (pch << 8) | pcl) + 1) & 0xFFFFFF
ret_sp = (sp + 3) & 0xFFFF
_btPrintFrame(state, 1, ret_pc, ret_sp)
print(" (no .debug_frame_w65816 — only one frame available)")
return
# Modern path: walk up via per-function frame records.
cur_pc = pc
cur_sp = sp
# First-frame guard: when MAME breaks AT a function entry, the
# prologue hasn't executed yet, so S points just below the
# caller's JSL triplet (no frame allocated). Pass the frame
# size as 0 for the first hop in that case. Later hops always
# have a fully-set-up frame since we're looking at the caller
# which is mid-execution by definition.
first_hop_at_entry = False
rec0 = pc2line.frameAt(state.frameRecords, cur_pc)
if rec0 is not None and rec0[0] == cur_pc:
first_hop_at_entry = True
for frame_no in range(1, BT_MAX_FRAMES + 1):
rec = pc2line.frameAt(state.frameRecords, cur_pc)
if rec is None:
# PC outside any recorded function (e.g. hand-written
# assembly with no .debug_frame_w65816 record). Without
# a frame size we can't safely climb past this point.
print(f" (no frame record for PC=0x{cur_pc:06x}"
f"stopping)")
return
_pc_start, _pc_end, frame_sz, rtl = rec
# Return-address triplet lives at cur_sp + frame_sz + 1..+3
# *except* when we're stopped at the function's first byte
# (the prologue hasn't allocated the frame yet), in which
# case the triplet is at cur_sp + 1..+3. See first_hop_at_entry.
effective_frame_sz = 0 if (frame_no == 1 and first_hop_at_entry) \
else frame_sz
ret_base = (cur_sp + effective_frame_sz) & 0xFFFF
pcl = state.lastStackBytes.get((ret_base + 1) & 0xFFFF)
pch = state.lastStackBytes.get((ret_base + 2) & 0xFFFF)
pbr = state.lastStackBytes.get((ret_base + 3) & 0xFFFF)
if pcl is None or pch is None or pbr is None:
print(f" (return triplet at 0x{ret_base+1:04x}.."
f"0x{ret_base+3:04x} not in captured stack window — "
f"stopping)")
return
ret_pc = (((pbr << 16) | (pch << 8) | pcl) + 1) & 0xFFFFFF
# New S after the popped JSL triplet: same arithmetic as the
# epilogue's RTL would do (S += 3). rtl_bytes is reserved for
# future inline JSR/RTS subroutines (2 bytes) — for the
# current ABI all calls are JSL/RTL so rtl is always 3.
ret_sp = (ret_base + rtl) & 0xFFFF
# Stop once we've climbed past the initial program-entry SP —
# that means we've returned out of main() into crt0 / GS/OS
# Loader scaffolding, where the frame record doesn't apply.
if ret_sp > BT_ROOT_SP:
_btPrintFrame(state, frame_no, ret_pc, ret_sp)
print(f" (reached crt0 / program-entry frame "
f"S=0x{ret_sp:04x} > 0x{BT_ROOT_SP:04x})")
return
# Stop if the unwind made no progress (cycle or pathological
# rtl-byte mismatch). Pure defensive check; the constants
# above keep the legitimate path monotonic.
if ret_sp <= cur_sp:
print(f" (non-monotonic SP at frame #{frame_no} "
f"cur=0x{cur_sp:04x} new=0x{ret_sp:04x} — stopping)")
return
_btPrintFrame(state, frame_no, ret_pc, ret_sp)
cur_pc = ret_pc
cur_sp = ret_sp
print(f" (>{BT_MAX_FRAMES} frames — truncated)")
def replPrintSymbol(state, spec):
"""Decode a symbol's bytes from the last snapshot and print them
per the symbol's DWARF type. If the symbol hasn't been watched
yet (or no run has happened), instruct the user to `run` first.
"""
addr = None
for a, s in state.syms:
if s == spec:
addr = a
break
if addr is None:
print(f" no such symbol: {spec!r}")
return
# Make sure it's queued as a watch for the next run.
if spec not in state.watches:
sz = state.symbolSize(spec)
if sz is None or sz <= 0:
sz = 2
if sz > 64:
# Truncate: large structs/arrays surface the first 64 bytes.
sz = 64
state.watches[spec] = (addr, sz)
if state.lastSnap is None or not state.lastWatchBytes:
print(f" &{spec} = 0x{addr:06x} (watch queued — run to capture)")
return
addr_w, length = state.watches[spec]
bytes_ = bytearray(length)
have_all = True
for i in range(length):
b = state.lastWatchBytes.get(addr_w + i)
if b is None:
have_all = False
break
bytes_[i] = b
type_str = state.typeStrOfSymbol(spec)
if not have_all:
print(f" {spec}: ADDR=0x{addr:06x} TYPE={type_str} "
f"(no snapshot bytes — run again to capture)")
return
decoded = _decodeBytes(type_str, bytes_)
hex_dump = " ".join(f"{b:02x}" for b in bytes_)
print(f" {spec} : {type_str} = {decoded}")
print(f" ADDR=0x{addr:06x} BYTES=[{hex_dump}]")
def _decodeBytes(type_str, raw):
"""Best-effort C-value print for a small byte buffer.
Recognises:
- int/short/char (1/2/4 byte ints, little-endian)
- unsigned variants
- any "* " (pointer) type — print as hex address
- struct/union — show raw hex (the caller already prints BYTES=)
Floats are out of scope per the task; print bytes as hex.
"""
ts = type_str.strip()
if not raw:
return "<empty>"
# Pointer types -> print as hex address of the right width.
if ts.endswith("*") or " *" in ts:
if len(raw) >= 4:
v = raw[0] | (raw[1] << 8) | (raw[2] << 16) | (raw[3] << 24)
return f"0x{v & 0xFFFFFFFF:08x}"
if len(raw) >= 2:
v = raw[0] | (raw[1] << 8)
return f"0x{v:04x}"
return f"0x{raw[0]:02x}"
# Integer base types.
int_widths = {
"char": 1, "signed char": 1, "unsigned char": 1,
"_Bool": 1, "bool": 1,
"short": 2, "short int": 2,
"unsigned short": 2, "unsigned short int": 2,
"int": 2, "unsigned int": 2, "signed int": 2,
"long": 4, "long int": 4, "signed long": 4,
"unsigned long": 4, "unsigned long int": 4,
"long long": 4, "unsigned long long": 4,
}
signed_set = {"char", "signed char", "short", "short int",
"int", "signed int", "long", "long int",
"signed long", "long long"}
if ts in int_widths:
w = int_widths[ts]
n = min(w, len(raw))
v = 0
for i in range(n):
v |= raw[i] << (8 * i)
if ts in signed_set:
top = 1 << (8 * n - 1)
if v & top:
v = v - (1 << (8 * n))
return f"{v} (0x{v & ((1 << (8*n)) - 1):0{2*n}x})"
# struct / union / class — caller dumps raw bytes.
if ts.startswith("struct ") or ts.startswith("union ") \
or ts.startswith("class "):
# Show u16 words as a partial decode hint (often the first
# field is an integer the user wants to see).
if len(raw) >= 2:
first_u16 = raw[0] | (raw[1] << 8)
return f"<{ts}; first u16 = 0x{first_u16:04x}>"
return f"<{ts}>"
# Array type — show first elements as best-effort integers.
if "[" in ts and ts.endswith("]"):
first = " ".join(f"0x{b:02x}" for b in raw[:8])
return f"[{first}{', ...' if len(raw) > 8 else ''}]"
return "<no decoder>"
def replInfoLocals(state):
"""Show formal_parameters + locals at the last snapshot PC."""
if state.lastSnap is None:
print(" no snapshot yet — `run` first")
return
pc = state.lastSnap["pc"]
sp = state.lastSnap["sp"]
cu, sub, locs = pc2line.localsAtPc(state.cus, pc, sp_value=sp)
if sub is None:
print(f" no subprogram at PC=0x{pc:06x}")
return
sub_name = pc2line.dieName(cu, sub) or "<unnamed>"
print(f" in {sub_name!r} at PC=0x{pc:06x} S=0x{sp:04x}")
if not locs:
print(" (no formal_parameter / variable in scope)")
return
for name, ty, loc, _die in locs:
if loc.kind == "memory":
print(f" {name} : {ty} ADDR=0x{loc.addr:06x}")
elif loc.kind == "register":
if loc.dp_addr is not None:
print(f" {name} : {ty} REG=DW{loc.reg_dw} "
f"ADDR=0x{loc.dp_addr:06x}")
else:
print(f" {name} : {ty} REG=DW{loc.reg_dw}")
elif loc.kind == "value":
print(f" {name} : {ty} VALUE=0x{loc.value:x}")
else:
print(f" {name} : {ty} UNSUPPORTED={loc.reason}")
def replNextLinePc(state, current_pc):
"""Return the PC of the DWARF line entry strictly after current_pc,
or None if there isn't one (end of program / no DWARF).
"""
# The line table is unsorted in source order; iterate to find the
# smallest entry whose PC is strictly greater than current_pc.
best = None
for pc, _fidx, _ln, _ft in state.lineTable:
if pc > current_pc:
if best is None or pc < best:
best = pc
return best
def replLoop(state):
"""Run the REPL. Reads commands from stdin, dispatches each one."""
interactive_tty = sys.stdin.isatty()
if interactive_tty:
print("mameDebug REPL. Type ? for help.")
while True:
try:
if interactive_tty:
line = input("(dbg) ")
else:
line = input() # no prompt in batch mode (cleaner output)
except EOFError:
if interactive_tty:
print()
break
line = line.strip()
if not line or line.startswith("#"):
continue
# Echo command in batch mode so the smoke test can diff output.
if not interactive_tty:
print(f"(dbg) {line}")
cmd, _, rest = line.partition(" ")
rest = rest.strip()
if cmd in ("q", "quit", "exit"):
break
if cmd == "?" or cmd == "help":
print(REPL_HELP)
continue
if cmd in ("break", "b"):
if not rest:
print(" usage: break <sym|file:line|0xADDR>")
continue
pc, label = state.resolveSpec(rest)
if pc is None:
print(f" cannot resolve: {label}")
continue
state.breakpoints.append((pc, label))
idx = len(state.breakpoints)
print(f" bp #{idx} at 0x{pc:06x} ({label})")
continue
if cmd in ("info",):
if rest == "breakpoints":
if not state.breakpoints:
print(" no breakpoints")
else:
for i, (pc, lab) in enumerate(state.breakpoints, 1):
print(f" #{i} 0x{pc:06x} ({lab})")
continue
if rest == "locals":
replInfoLocals(state)
continue
print(f" unknown info subcommand: {rest!r}")
continue
if cmd == "delete":
try:
idx = int(rest)
except ValueError:
print(" usage: delete <N>")
continue
if idx < 1 or idx > len(state.breakpoints):
print(f" no breakpoint #{idx}")
continue
del state.breakpoints[idx - 1]
print(f" deleted bp #{idx}")
continue
if cmd in ("run", "r", "continue", "c"):
if not state.breakpoints:
print(" no breakpoints set — nothing to break on")
continue
bp_pcs = [pc for pc, _ in state.breakpoints]
# Decide start_pc. Precedence (highest first):
# --from-start -> LOAD_AT (run through crt0)
# --start-at -> user-supplied entry point (FUNC or hex)
# — set this to an *outer* caller of the
# bp so the JSL frame chain is real and
# `bt` can walk multiple frames.
# default -> jump straight to the first bp (matches
# --trace behaviour; produces a single
# frame in `bt`).
if state.args.from_start:
start_pc = state.args.load_at
elif state.args.start_at:
spec = state.args.start_at
try:
start_pc = int(spec, 0)
except ValueError:
start_pc = None
for addr, sym in state.syms:
if sym == spec:
start_pc = addr
break
if start_pc is None:
print(f" --start-at '{spec}' not in map; "
f"falling back to bp[0]")
start_pc = bp_pcs[0]
else:
start_pc = bp_pcs[0]
watch_regions = list(state.watches.values())
replLaunchMame(state, bp_pcs, start_pc, watch_regions,
seconds=state.args.seconds)
if state.lastSnap is None:
print(" WARN: no BP-HIT captured (timed out?)")
else:
replPrintWhere(state)
continue
if cmd in ("step", "s", "next", "n"):
# Both map to "advance to next source line via DWARF" in
# our snapshot-based model. Requires a prior snapshot to
# know "where we are".
if state.lastSnap is None:
# No prior snapshot: just do `run` (start of program).
if not state.breakpoints:
print(" no breakpoints set — `break` first")
continue
bp_pcs = [pc for pc, _ in state.breakpoints]
start_pc = (state.args.load_at if state.args.from_start
else bp_pcs[0])
replLaunchMame(state, bp_pcs, start_pc,
list(state.watches.values()),
seconds=state.args.seconds)
if state.lastSnap is not None:
replPrintWhere(state)
continue
current_pc = state.lastSnap["pc"]
next_pc = replNextLinePc(state, current_pc)
if next_pc is None:
print(" no next DWARF line entry — at end of program")
continue
print(f" stepping to next DWARF line at 0x{next_pc:06x}")
replLaunchMame(state, [next_pc], current_pc,
list(state.watches.values()),
seconds=state.args.seconds)
if state.lastSnap is None:
print(" WARN: step did not hit the bp (timed out?)")
else:
replPrintWhere(state)
continue
if cmd == "where":
replPrintWhere(state)
continue
if cmd in ("bt", "backtrace"):
replPrintBacktrace(state)
continue
if cmd in ("print", "p"):
if not rest:
print(" usage: print <symbol>")
continue
replPrintSymbol(state, rest)
continue
print(f" unknown command: {line!r} (try ?)")
return 0
def replMode(args):
"""Entry point for `--repl`."""
state = ReplState(args)
if args.break_at:
# --break is interpreted as "queue this bp before reading any
# interactive commands" — useful when scripting.
pc, label = state.resolveSpec(args.break_at)
if pc is None:
print(f"mameDebug: --break {args.break_at!r}: {label}",
file=sys.stderr)
return 2
state.breakpoints.append((pc, label))
print(f" bp #1 at 0x{pc:06x} ({label}) [from --break]")
return replLoop(state)
# ---- main ------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="GDB-style debugger front-end for W65816 + MAME")
ap.add_argument("--bin", required=True, help="flat .bin image to load")
ap.add_argument("--map", required=True, help="link816 .map")
ap.add_argument("--dwarf", required=True, help="link816 --debug-out sidecar")
ap.add_argument("--load-at", type=lambda s: int(s, 0), default=0x1000,
help="bank-0 load address (default 0x1000)")
ap.add_argument("--break", dest="break_at", default=None,
help="breakpoint for --trace (FUNC or FILE:LINE). "
"Default: 'main'")
ap.add_argument("--seconds", type=int, default=4,
help="MAME simulated seconds (default 4)")
ap.add_argument("--trace", action="store_true",
help="default-on smoke mode: set bp, capture one "
"BP-HIT, resolve via pc2line, exit 0")
ap.add_argument("--repl", action="store_true",
help="interactive REPL. Reads stdin commands "
"(break/run/step/next/where/bt/print/info/"
"delete/quit). Each `run`/`step`/`next` "
"launches one MAME process. `print`, `bt`, "
"and `where` decode the captured snapshot "
"and need no further MAME launch.")
ap.add_argument("--from-start", action="store_true",
help="start execution at LOAD_AT (i.e. through "
"the crt0). Default is to jump straight to "
"the bp target — required for crt0Gsos/Gno "
"binaries since their startup expects the "
"GS/OS Loader to have applied relocations.")
ap.add_argument("--start-at", default=None,
help="override the initial PC: FUNC name or hex "
"address. Default = the bp target. Use to "
"set bp inside a deeper callee while still "
"starting from main() (so the JSL frame is "
"on the stack for --finish).")
ap.add_argument("--finish", action="store_true",
help="trace + finish: also install a one-shot bp "
"at the breakpointed function's RTL return "
"address, prove the entry+return pair fires "
"end-to-end. Drives the `finish`-command "
"primitive in the interactive shell.")
ap.add_argument("--verbose", "-v", action="store_true",
help="dump full MAME output to stderr")
args = ap.parse_args()
if not os.path.exists(args.bin):
print(f"mameDebug: missing --bin {args.bin}", file=sys.stderr)
return 2
if not os.path.exists(args.map):
print(f"mameDebug: missing --map {args.map}", file=sys.stderr)
return 2
if not os.path.exists(args.dwarf):
print(f"mameDebug: missing --dwarf {args.dwarf}", file=sys.stderr)
return 2
if args.trace:
return traceMode(args)
if args.repl:
return replMode(args)
return interactiveMode(args)
if __name__ == "__main__":
sys.exit(main())