#!/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 , 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 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 decode bytes at &symbol per DWARF type # info locals show formal_parameters + locals # info breakpoints list queued breakpoints # delete 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 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 decode bytes at &symbol per DWARF type info locals show formal_parameters + locals info breakpoints list queued breakpoints delete 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 ` # 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 # 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 "" # 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 "" 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 "" 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 ") 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 ") 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 ") 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())