1403 lines
55 KiB
Python
Executable file
1403 lines
55 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 64 bytes of the stack window above S (S+1 .. S+64).
|
|
-- That's where the topmost JSL return frame lives.
|
|
for ofs = 1, 64 do
|
|
local addr = s_val + ofs
|
|
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)
|
|
# 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 replPrintBacktrace(state):
|
|
"""Walk the JSL return frame chain starting from the captured S.
|
|
|
|
The W65816 JSL pushes 3 bytes per call (PCL, PCH, PBR). Our ABI is
|
|
empty-descending: S points to the next-free byte. So the topmost
|
|
return-address triplet lives at S+1, S+2, S+3. We read it from the
|
|
captured stack window. We have no DW_AT_frame_base / DW_CFA_*
|
|
sidecar yet, so we can't walk past one frame — but we can show the
|
|
return address of the current function, which is what most debug
|
|
sessions need anyway.
|
|
"""
|
|
if state.lastSnap is None:
|
|
print(" no snapshot yet — `run` first")
|
|
return
|
|
pc = state.lastSnap["pc"]
|
|
sp = state.lastSnap["sp"]
|
|
func = pc2line.funcAt(state.syms, pc)
|
|
row = pc2line.query(state.lineTable, pc)
|
|
if row is None:
|
|
print(f" #0 PC=0x{pc:06x} FUNC={func}")
|
|
else:
|
|
_, fname, ln = row
|
|
print(f" #0 PC=0x{pc:06x} {fname}:{ln} FUNC={func}")
|
|
# Try to read S+1..S+3 from the captured stack window.
|
|
pcl_addr = (sp + 1) & 0xFFFF
|
|
pch_addr = (sp + 2) & 0xFFFF
|
|
pbr_addr = (sp + 3) & 0xFFFF
|
|
pcl = state.lastStackBytes.get(pcl_addr)
|
|
pch = state.lastStackBytes.get(pch_addr)
|
|
pbr = state.lastStackBytes.get(pbr_addr)
|
|
if pcl is None or pch is None or pbr is None:
|
|
print(" #1 <return address not in captured stack window>")
|
|
return
|
|
# JSL pushes the address of the LAST byte of the JSL instruction,
|
|
# so the actual return target is ret_addr + 1.
|
|
ret_pc = (pbr << 16) | (pch << 8) | pcl
|
|
ret_pc = (ret_pc + 1) & 0xFFFFFF
|
|
ret_func = pc2line.funcAt(state.syms, ret_pc)
|
|
ret_row = pc2line.query(state.lineTable, ret_pc)
|
|
if ret_row is None:
|
|
print(f" #1 PC=0x{ret_pc:06x} FUNC={ret_func}")
|
|
else:
|
|
_, fname, ln = ret_row
|
|
print(f" #1 PC=0x{ret_pc:06x} {fname}:{ln} FUNC={ret_func}")
|
|
|
|
|
|
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: --from-start runs through crt0; default
|
|
# is to jump to the first bp (matches --trace behaviour).
|
|
if state.args.from_start:
|
|
start_pc = state.args.load_at
|
|
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())
|