#!/usr/bin/env python3
# mameDebug.py - Python front-end for source-level debugging of W65816
# binaries inside MAME.  Wraps MAME's autoboot-Lua + -debug -oslog stream
# into a GDB-style interactive prompt plus a default-on --trace check
# that drives the source-PC resolver end-to-end.
#
# Phase 3.1 of the gap-closure plan.
#
# Two modes:
#
#   --trace       Set bp at `main` (or another symbol), run until first
#                 BP-HIT line surfaces on -oslog, capture the PC, resolve
#                 it through scripts/pc2line.py.  Exits 0 on resolved
#                 hit.  This is the default-on smoke check; it runs
#                 unconditionally in scripts/smokeTest.sh.
#
#   (default)     Interactive (dbg) prompt — gated behind DEBUGGER_E2E=1
#                 in the environment, because driving MAME's debugger
#                 across a TTY isn't reliable in CI.  Supports the GDB
#                 subset: b/c/s/n/finish/p &SYM/q.
#
# Critical reviewer-flagged constraints (do not violate):
#   - cpu.debug:bpset(addr) ONE-arg form CRASHES MAME.  Always use the
#     3-arg form:
#         bpset(pc, '', 'logerror "BP-HIT PC=%X A=%X X=%X Y=%X S=%X DBR=%X\\n",pc,a,x,y,s,db; go')
#   - DO NOT call cpu.debug:go() from add_machine_pause_notifier
#     callbacks (reentrancy SEGFAULT — see SESSION_RECOVERY.md).
#   - MAME under -debug starts with execution_state='stop'.  The Lua
#     boot script must explicitly assign 'run' to kick simulation.
#   - Multi-frame `bt` is out of scope — requires DW_AT_frame_base or
#     per-function frame-size sidecar.  `finish` is provided instead.
#
# Usage:
#   scripts/mameDebug.py --trace --bin demos/helloBeep_dbg.bin \
#                        --map demos/helloBeep_dbg.map \
#                        --dwarf demos/helloBeep_dbg.dwarf \
#                        [--break main]
#
#   DEBUGGER_E2E=1 scripts/mameDebug.py --bin ... --map ... --dwarf ...

import argparse
import importlib.util
import os
import re
import subprocess
import sys
import tempfile


SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT       = os.path.dirname(SCRIPT_DIR)


# Import pc2line.py as a module so the REPL can reuse its DWARF parsing
# (line table, DIE walking, type chains, locals evaluator) without
# shelling out + reparsing on every command.  pc2line.py is the single
# source of truth for DWARF semantics; we must NOT duplicate any of it.
def _loadPc2lineModule():
    spec = importlib.util.spec_from_file_location(
        "pc2line", os.path.join(SCRIPT_DIR, "pc2line.py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


pc2line = _loadPc2lineModule()


# ---- Map + DWARF helpers ---------------------------------------------

def loadMapSyms(path):
    """Parse a link816 .map.  Return [(addr, sym), ...] sorted ascending."""
    syms = []
    with open(path) as f:
        for ln in f:
            ln = ln.strip()
            if not ln.startswith("0x"):
                continue
            parts = ln.split()
            if len(parts) >= 2:
                try:
                    syms.append((int(parts[0], 16), parts[1]))
                except ValueError:
                    pass
    syms.sort()
    return syms


def lookupSym(syms, name):
    """Return address for the named symbol, or None."""
    for addr, sym in syms:
        if sym == name:
            return addr
    return None


def resolveBreakpoint(spec, syms, dwarf, mapPath):
    """Resolve `FUNC` or `FILE:LINE` to a 24-bit PC.  Returns int or None."""
    if ":" in spec:
        # FILE:LINE — dump pc2line table and grep.
        file_part, line_part = spec.rsplit(":", 1)
        try:
            line_num = int(line_part)
        except ValueError:
            return None
        # Use pc2line --dump.
        cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
               "--sidecar", dwarf, "--map", mapPath, "--dump"]
        out = subprocess.check_output(cmd, text=True)
        for ln in out.splitlines():
            parts = ln.split()
            if len(parts) < 2:
                continue
            pc_hex, file_line = parts[0], parts[1]
            if ":" not in file_line:
                continue
            f, l = file_line.rsplit(":", 1)
            if f == file_part and l == str(line_num):
                return int(pc_hex, 16)
        return None
    # Pure symbol name.
    return lookupSym(syms, spec)


# ---- Lua boot script builder ----------------------------------------

LUA_TEMPLATE = r"""
-- mameDebug autoboot script (generated by scripts/mameDebug.py)
local BIN_PATH  = "{bin_path}"
local LOAD_AT   = 0x{load_at:04x}
local START_PC  = 0x{start_pc:06x}
local BPS       = {{ {bp_list} }}
local FINISH    = {finish_lua}

local installed = false
local frame = 0
local finish_state = "armed"   -- "armed" -> "ret-installed" -> "done"
local cpu, dbg, mem

emu.register_frame_done(function()
    frame = frame + 1
    if frame == 30 and not installed then
        cpu = manager.machine.devices[":maincpu"]
        dbg = cpu.debug
        mem = cpu.spaces["program"]
        local f = io.open(BIN_PATH, "rb")
        if not f then
            print("MAMEDBG-BIN-MISSING " .. BIN_PATH)
            manager.machine:exit()
            return
        end
        local data = f:read("*all")
        f:close()
        -- Skip the IIgs IO window; otherwise stray rodata pad bytes can
        -- clobber soft switches.  Matches runInMame.sh.
        for i = 1, #data do
            local addr = LOAD_AT + i - 1
            if not (addr >= 0x00C000 and addr < 0x00D000) then
                mem:write_u8(addr, data:byte(i))
            end
        end
        -- START_PC may be either LOAD_AT (run crt0 first; requires the
        -- crt0 to work standalone — true for crt0.s smoke harness,
        -- NOT for crt0Gsos.s which expects Loader-applied relocations)
        -- or the bp target itself (jump-to-bp; works for any image).
        -- The Python front-end picks based on whether the binary's
        -- __start is the OMF-style crt0 or the flat smoke crt0.
        cpu.state["PC"].value = START_PC
        cpu.state["PB"].value = 0x00
        cpu.state["DB"].value = 0x00
        cpu.state["D"].value  = 0x00
        -- P=0x04 (M=0, X=0, I=0): matches the state crt0 leaves before
        -- JSL main, so jumping straight to main with this P is honest.
        -- Demos that bp before crt0 finishes still work — bpset matches
        -- on the PC regardless of P.
        cpu.state["P"].value  = 0x04
        cpu.state["E"].value  = 0
        cpu.state["S"].value  = 0x01FF
        -- Install breakpoints in the 3-arg form (the 1-arg form crashes
        -- MAME).  `; go` resumes execution from the action string itself,
        -- avoiding the reentrancy SEGFAULT documented in SESSION_RECOVERY.
        -- If FINISH is true, the action also stamps the 24-bit return
        -- PC (read from the JSL frame on the stack: PCL@s+1, PCH@s+2,
        -- PBR@s+3) plus a 0xFEED marker into bank-2 scratch
        -- ($020000..$020005) so the register_periodic poller can read
        -- it and install a one-shot bp at the post-call PC.  Nested
        -- bpset inside the action string itself does NOT fire in this
        -- MAME version (verified by spike), so we route the install
        -- through register_periodic.
        for _, pc in ipairs(BPS) do
            local action
            if FINISH then
                action = 'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; ' ..
                         'w@0x020000=b@(s+1) + (b@(s+2)<<8); w@0x020002=b@(s+3); w@0x020004=0xFEED; go'
            else
                action = 'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; go'
            end
            dbg:bpset(pc, '', action)
        end
        -- Resume execution.  Under -debug MAME pauses at startup; the
        -- bpset action's "; go" tail handles re-resuming after each
        -- hit, but the FIRST kick needs an explicit :go() from the
        -- autoboot script.  register_frame_done is a safe context
        -- (NOT the add_machine_pause_notifier path which has the
        -- documented reentrancy SEGFAULT).
        dbg:go()
        print(string.format("MAMEDBG-LOADED bytes=%d bps=%d finish=%s", #data, #BPS, tostring(FINISH)))
        installed = true
    end
    if frame == {exit_frame} then
        print("MAMEDBG-EXIT frame=" .. frame)
        manager.machine:exit()
    end
end)

-- Finish poller: when the entry bp has fired (marker == 0xFEED),
-- read the return-PC triplet from bank-2 scratch and install a
-- one-shot bp at (PC + 1).  Polling cost: a couple of mem reads per
-- periodic tick; install latency vs RTL determines whether the bp
-- catches the function before it exits.  For typical main() with
-- substantial body, the latency is fine.  For 3-NOP toys, the bp
-- may install after RTL — that's an acceptable proof-of-concept
-- limitation noted in the docstring.
emu.register_periodic(function()
    if not FINISH or finish_state ~= "armed" or not mem then return end
    local marker = mem:read_u16(0x020004)
    if marker == 0xFEED then
        local ret_lo16 = mem:read_u16(0x020000)
        local ret_bank = mem:read_u8(0x020002)
        local ret_pc   = (ret_bank * 0x10000) + ret_lo16 + 1
        dbg:bpset(ret_pc, '',
            'logerror "MAMEDBG-RET PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; go')
        print(string.format("MAMEDBG-FINISH-ARMED ret_pc=0x%06X", ret_pc))
        finish_state = "ret-installed"
        mem:write_u16(0x020004, 0)
    end
end)
"""


def buildLuaScript(bin_path, load_at, bp_pcs, exit_frame, start_pc=None,
                   finish=False):
    """Return Lua autoboot script text.

    start_pc selects the initial PC after the binary is written to RAM.
    None means "run from load_at" (i.e. through the crt0); pass a
    specific PC to jump straight to a breakpoint target — required for
    crt0Gsos / crt0Gno images whose startup expects the GS/OS Loader
    to have applied relocations.

    finish=True turns each entry bp into an entry+return pair.  At the
    entry bp, the action stamps the 24-bit return PC into bank-2
    scratch.  A register_periodic poller reads the marker and installs
    a one-shot bp at (return_PC + 1).  Verified end-to-end against a
    long-running synthetic callee in the spike harness.
    """
    bp_list = ", ".join(f"0x{p:06x}" for p in bp_pcs)
    if start_pc is None:
        start_pc = load_at
    return LUA_TEMPLATE.format(
        bin_path    = bin_path,
        load_at     = load_at,
        start_pc    = start_pc,
        bp_list     = bp_list,
        exit_frame  = exit_frame,
        finish_lua  = "true" if finish else "false",
    )


# ---- MAME launcher ---------------------------------------------------

def runMame(lua_path, seconds, debug_flag, oslog=True):
    """Launch MAME under autoboot, return combined stdout+stderr text."""
    env = dict(os.environ)
    env["SDL_VIDEODRIVER"] = "dummy"
    env["SDL_AUDIODRIVER"] = "dummy"
    rom_path = os.path.join(ROOT, "tools/mame/roms")
    args = ["mame", "apple2gs",
            "-rompath", rom_path,
            "-ramsize", "1m",
            "-window",
            "-seconds_to_run", str(seconds),
            "-autoboot_script", lua_path,
            "-video", "none", "-sound", "none", "-nothrottle"]
    if debug_flag:
        # -debugger none keeps us headless while -debug enables bpset
        # plumbing.  -oslog routes `logerror` output to stderr where we
        # can grep MAMEDBG-BP lines.
        args[1:1] = ["-debug", "-debugger", "none"]
    if oslog:
        args.append("-oslog")
    timeout_s = seconds + 20  # generous: mame startup is ~5-8s
    try:
        proc = subprocess.run(
            args, env=env, capture_output=True, text=True,
            timeout=timeout_s)
    except subprocess.TimeoutExpired as e:
        return (e.stdout or "") + (e.stderr or "")
    return proc.stdout + proc.stderr


# ---- --trace mode ----------------------------------------------------

# `logerror` lines look like:
#   MAMEDBG-BP PC=106E A=1234 X=0 Y=38 S=1FF DBR=0
BP_RE = re.compile(
    r"MAMEDBG-BP\s+PC=([0-9A-Fa-f]+)\s+A=([0-9A-Fa-f]+)\s+X=([0-9A-Fa-f]+)"
    r"\s+Y=([0-9A-Fa-f]+)\s+S=([0-9A-Fa-f]+)\s+DBR=([0-9A-Fa-f]+)")
RET_RE = re.compile(
    r"MAMEDBG-RET\s+PC=([0-9A-Fa-f]+)\s+A=([0-9A-Fa-f]+)\s+X=([0-9A-Fa-f]+)"
    r"\s+Y=([0-9A-Fa-f]+)\s+S=([0-9A-Fa-f]+)\s+DBR=([0-9A-Fa-f]+)")


def traceMode(args):
    """--trace: set bp at <break>, run, capture first BP-HIT, resolve PC.

    When --finish is also passed: at the entry bp, additionally install
    a one-shot bp at the function's RTL return address (read from the
    24-bit JSL frame on the stack at S+1..S+3) and continue.  The
    second bp fires after the function returns — proving the
    `finish`-command primitive end-to-end via the bpset-with-action-
    string mechanism (no reentrancy hazard, no host-side polling loop).
    """
    syms = loadMapSyms(args.map)
    target = args.break_at or "main"
    pc = resolveBreakpoint(target, syms, args.dwarf, args.map)
    if pc is None:
        print(f"mameDebug: cannot resolve breakpoint '{target}'", file=sys.stderr)
        return 2
    print(f"[trace] break {target} -> 0x{pc:06x}")

    load_at = args.load_at
    # Default: jump straight to the bp target.  crt0Gsos / crt0Gno
    # binaries' __start expects the GS/OS Loader to have already
    # applied IMM24 relocations, which isn't the case when we load
    # the flat .bin into bank 0 directly.  --from-start forces start
    # at LOAD_AT (use only with crt0.s smoke binaries, which run
    # standalone).  --start-at overrides with a user-supplied entry
    # point (FUNC or hex) — useful with --finish where the bp is a
    # deep callee and we want to start at its outer caller so the JSL
    # frame is set up.
    if args.from_start:
        start_pc = load_at
    elif args.start_at:
        spec = args.start_at
        try:
            start_pc = int(spec, 0)
        except ValueError:
            start_pc = lookupSym(syms, spec)
            if start_pc is None:
                print(f"mameDebug: --start-at '{spec}' not in map",
                      file=sys.stderr)
                return 2
    else:
        start_pc = pc
    lua_text = buildLuaScript(
        args.bin, load_at, [pc], exit_frame=120,
        start_pc=start_pc,
        finish=args.finish,
    )
    with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False) as lf:
        lf.write(lua_text)
        lua_path = lf.name
    try:
        out = runMame(lua_path, seconds=args.seconds, debug_flag=True)
    finally:
        os.unlink(lua_path)

    if args.verbose:
        sys.stderr.write(out)

    bps = []
    rets = []
    for ln in out.splitlines():
        m = BP_RE.search(ln)
        if m:
            bps.append(m.group(1))
        m = RET_RE.search(ln)
        if m:
            rets.append(m.group(1))
    if not bps:
        print("[trace] FAIL: no BP-HIT in -oslog output", file=sys.stderr)
        # Print a sample of the output to diagnose
        tail = out.splitlines()[-20:]
        for ln in tail:
            sys.stderr.write(f"  > {ln}\n")
        return 1

    hit_pc = int(bps[0], 16)
    print(f"[trace] BP-HIT PC=0x{hit_pc:06x} (count={len(bps)})")

    # Run pc2line.py to resolve to source.
    cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
           "--sidecar", args.dwarf, "--map", args.map, f"0x{hit_pc:06x}"]
    resolved = subprocess.check_output(cmd, text=True).strip()
    print(f"[trace] {resolved}")
    # Assert pc2line resolved (non-empty FILE/LINE/FUNC).
    if "NOT_FOUND" in resolved or "FILE=?" in resolved:
        print("[trace] FAIL: pc2line could not resolve the captured PC",
              file=sys.stderr)
        return 1

    if args.finish:
        if not rets:
            print("[trace] FAIL: --finish requested but no MAMEDBG-RET "
                  "in -oslog output (function may have returned before "
                  "the register_periodic poller installed the ret bp; "
                  "see mameDebug.py docstring)", file=sys.stderr)
            return 1
        ret_pc = int(rets[0], 16)
        print(f"[trace] RET PC=0x{ret_pc:06x} (count={len(rets)})")
        cmd = ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
               "--sidecar", args.dwarf, "--map", args.map,
               f"0x{ret_pc:06x}"]
        ret_resolved = subprocess.check_output(cmd, text=True).strip()
        print(f"[trace] {ret_resolved}")

    print("[trace] OK")
    return 0


# ---- Interactive (dbg) prompt (gated behind DEBUGGER_E2E=1) ---------

INTERACTIVE_HELP = """
Commands:
  b FUNC | FILE:LINE   set breakpoint
  c                    continue
  s                    single-step instruction
  n                    step-over (temp-bp at jsl_pc+4, since JSL is 4B)
  finish               run-until-current-frame-RTL/RTS (i.e. until S
                       moves above its current value)
  p &GLOBAL            print address of a global symbol (map lookup)
  p VAR                print formal-parameter / local for current PC.
                       Uses the most-recent BP-HIT S register; routes
                       through pc2line.py --locals.
  q | quit             exit the debugger
  ?                    this help
"""


def interactiveMode(args):
    """Stub interactive prompt — gated behind DEBUGGER_E2E=1.

    The trace-mode harness covers the load-set-bp-resolve-PC end-to-end
    path with a single capture.  An honest interactive loop would need
    a bidirectional MAME-Lua RPC (request-reply over a socket, since
    -oslog is one-way stderr).  That's deferred to a follow-up.

    For now the gated path:
      - Builds and runs the Lua bootstrap with user-supplied --break
        list.
      - Forwards each BP-HIT line through pc2line for resolution.
      - Reads commands from stdin but only honors `b SYM_or_FILE:LINE`
        (queued before launch), `c` (no-op confirming continue), `q`
        (exit).  Step/finish/print are accepted at parse time but
        unimplemented in this slice — they print TODO.

    The pieces required for true interactive control (debugger-RPC
    socket, machine.debugger.command() from a sequencer Lua coroutine)
    are wired up in `mameDebug.lua.tmpl` for future work; the prompt
    here just demonstrates the parser surface.
    """
    if os.environ.get("DEBUGGER_E2E", "0") != "1":
        print("mameDebug: interactive mode is gated behind DEBUGGER_E2E=1",
              file=sys.stderr)
        print("           use --trace for the smoke-checkable path",
              file=sys.stderr)
        return 2

    syms = loadMapSyms(args.map)
    print("mameDebug interactive (DEBUGGER_E2E=1).  Type ? for help.")
    print(INTERACTIVE_HELP)

    bp_pcs = []
    last_hit_pc = None
    last_hit_sp = None
    while True:
        try:
            line = input("(dbg) ").strip()
        except EOFError:
            print()
            break
        if not line:
            continue
        if line in ("q", "quit"):
            break
        if line == "?":
            print(INTERACTIVE_HELP)
            continue
        if line.startswith("b "):
            spec = line[2:].strip()
            pc = resolveBreakpoint(spec, syms, args.dwarf, args.map)
            if pc is None:
                print(f"  cannot resolve {spec!r}")
                continue
            bp_pcs.append(pc)
            print(f"  breakpoint at 0x{pc:06x}")
            continue
        if line == "c":
            if not bp_pcs:
                print("  no breakpoints set; nothing to continue toward")
                continue
            # Launch one MAME run with the queued bps, surface every hit.
            start_pc = args.load_at if args.from_start else bp_pcs[0]
            lua_text = buildLuaScript(args.bin, args.load_at, bp_pcs,
                                      exit_frame=240, start_pc=start_pc)
            with tempfile.NamedTemporaryFile(
                    "w", suffix=".lua", delete=False) as lf:
                lf.write(lua_text)
                lua_path = lf.name
            try:
                out = runMame(lua_path, seconds=args.seconds,
                              debug_flag=True)
            finally:
                os.unlink(lua_path)
            for ln in out.splitlines():
                m = BP_RE.search(ln)
                if m:
                    hit_pc = int(m.group(1), 16)
                    hit_sp = int(m.group(5), 16)
                    last_hit_pc = hit_pc
                    last_hit_sp = hit_sp
                    resolved = subprocess.check_output(
                        ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
                         "--sidecar", args.dwarf, "--map", args.map,
                         f"0x{hit_pc:06x}"],
                        text=True).strip()
                    print(f"  HIT {resolved} (S=0x{hit_sp:04x})")
            continue
        if line in ("s", "n", "finish"):
            # These need request-reply with the simulator; not in this
            # slice.  See module docstring.
            print(f"  TODO: '{line}' requires bidirectional MAME RPC "
                  "(deferred follow-up — see mameDebug.py docstring)")
            continue
        if line.startswith("p &"):
            sym = line[3:].strip()
            addr = lookupSym(syms, sym)
            if addr is None:
                print(f"  no such symbol: {sym}")
            else:
                print(f"  &{sym} = 0x{addr:06x}")
            continue
        if line.startswith("p "):
            # `p VAR` — formal-parameter / local lookup at the most
            # recent BP-HIT.  Routes through pc2line.py --locals with
            # the captured PC + S.  Output is filtered to the line
            # whose VAR= matches `var` (if no match, all locals are
            # shown so the user can see what's in scope).
            var = line[2:].strip()
            if last_hit_pc is None or last_hit_sp is None:
                print("  no recent breakpoint hit; run `c` first")
                continue
            try:
                out = subprocess.check_output(
                    ["python3", os.path.join(SCRIPT_DIR, "pc2line.py"),
                     "--sidecar", args.dwarf, "--map", args.map,
                     "--locals", "--sp", f"0x{last_hit_sp:04x}",
                     f"0x{last_hit_pc:06x}"],
                    text=True)
            except subprocess.CalledProcessError as e:
                print(f"  pc2line --locals failed: {e}")
                continue
            shown = False
            for ln_out in out.splitlines():
                if ln_out.startswith(f"VAR={var} ") or \
                   ln_out.startswith(f"VAR={var}\t"):
                    print(f"  {ln_out}")
                    shown = True
            if not shown:
                # Variable name didn't match anything in scope.  Print
                # everything so the user can see what's available.
                for ln_out in out.splitlines():
                    print(f"  {ln_out}")
            continue
        print(f"  unknown command: {line!r}.  Type ? for help.")
    return 0


# ---- REPL mode (--repl) ---------------------------------------------
#
# An interactive prompt that gives `gdb`-flavour commands on top of the
# load-snapshot-resolve cycle.  Because MAME has no bidirectional Lua
# RPC channel under `-debugger none`, every "execute the program"
# command (run / continue / step / next) maps to one MAME process
# launch.  The Lua autoboot writes the program into bank-0 memory,
# installs all queued breakpoints, runs until the first hit, captures
# a register + memory snapshot, and exits.  The Python REPL then
# decodes the snapshot to answer `print`, `bt`, `where` from cached
# state — no further MAME launch needed for those.
#
# Commands:
#   break <sym|file:line|0xADDR>   set/queue a breakpoint
#   run | continue [c]             launch MAME, stop at first bp hit
#   step | next                    advance to next source line
#                                  (via DWARF line table; one bp install)
#   bt | backtrace                 walk the JSL frame chain from S
#   where                          PC -> source line for the last hit
#   print <symbol>                 decode bytes at &symbol per DWARF type
#   info locals                    show formal_parameters + locals
#   info breakpoints               list queued breakpoints
#   delete <N>                     remove breakpoint by index
#   quit | q                       exit
#   ?                              this help
#
# Smoke-checkable: pipe a script of `break main\nrun\nwhere\nquit\n`
# into `mameDebug.py --repl ...` and assert the BP-HIT + WHERE output.


REPL_HELP = """\
Commands:
  break <sym|file:line|0xADDR>   set/queue a breakpoint
  run | continue                 launch MAME, stop at first hit
  step | next                    advance to next source line (DWARF)
  bt | backtrace                 walk JSL frame chain from S
  where                          PC -> source line for the last hit
  print <symbol>                 decode bytes at &symbol per DWARF type
  info locals                    show formal_parameters + locals
  info breakpoints               list queued breakpoints
  delete <N>                     remove breakpoint by index
  quit | q                       exit
  ?                              this help
"""


# Lua autoboot for the REPL.  Differs from the --trace template in three
# ways:
#   1. Breakpoint actions also dump (a) a 64-byte stack window around S
#      and (b) per-symbol memory regions for `print` requests, both as
#      tagged log lines so the host can parse.
#   2. exit_frame is generous (240) so a slow run still completes.
#   3. The list of "watch" memory regions is parameterised — the host
#      stamps in (addr, len) pairs based on queued `print <symbol>`
#      requests.
REPL_LUA_TEMPLATE = r"""
-- mameDebug REPL autoboot (generated by scripts/mameDebug.py --repl)
local BIN_PATH = "{bin_path}"
local LOAD_AT  = 0x{load_at:04x}
local START_PC = 0x{start_pc:06x}
local BPS      = {{ {bp_list} }}
local WATCHES  = {{ {watch_list} }}    -- list of {{addr, len}} pairs

local installed = false
local frame = 0
local cpu, dbg, mem

emu.register_frame_done(function()
    frame = frame + 1
    if frame == 30 and not installed then
        cpu = manager.machine.devices[":maincpu"]
        dbg = cpu.debug
        mem = cpu.spaces["program"]
        local f = io.open(BIN_PATH, "rb")
        if not f then
            print("MAMEDBG-BIN-MISSING " .. BIN_PATH)
            manager.machine:exit()
            return
        end
        local data = f:read("*all")
        f:close()
        for i = 1, #data do
            local addr = LOAD_AT + i - 1
            if not (addr >= 0x00C000 and addr < 0x00D000) then
                mem:write_u8(addr, data:byte(i))
            end
        end
        cpu.state["PC"].value = START_PC
        cpu.state["PB"].value = 0x00
        cpu.state["DB"].value = 0x00
        cpu.state["D"].value  = 0x00
        cpu.state["P"].value  = 0x04
        cpu.state["E"].value  = 0
        cpu.state["S"].value  = 0x01FF

        -- Build the bp action.  We use the 3-arg bpset form (1-arg
        -- crashes MAME).  The action stamps a magic marker into bank-2
        -- scratch ($020010 / 0xDEAD) so the periodic poller can detect
        -- the hit and dump memory from a SAFE context (the action
        -- string itself can't call multi-statement loops cleanly).
        local action_template =
            'logerror "MAMEDBG-BP PC=%X A=%X X=%X Y=%X S=%X DBR=%X\n",pc,a,x,y,s,db; ' ..
            'w@0x020010=0xDEAD; w@0x020012=s; w@0x020014=pc & 0xFFFF; w@0x020016=(pc>>16) & 0xFF; go'
        for _, pc in ipairs(BPS) do
            dbg:bpset(pc, '', action_template)
        end
        print(string.format("MAMEDBG-LOADED bytes=%d bps=%d watches=%d",
                            #data, #BPS, #WATCHES))
        installed = true
    end
    if frame == {exit_frame} then
        print("MAMEDBG-EXIT frame=" .. frame)
        manager.machine:exit()
    end
end)

-- Marker-driven snapshot dumper.  Once the bp action stamps 0xDEAD at
-- $020010, this periodic handler reads S + PC from the scratch slots
-- and dumps the watched memory regions, then clears the marker.
local snapshotted = false
emu.register_periodic(function()
    if installed and not snapshotted and mem ~= nil then
        local marker = mem:read_u16(0x020010)
        if marker == 0xDEAD then
            local s_val  = mem:read_u16(0x020012)
            local pc_lo  = mem:read_u16(0x020014)
            local pc_bnk = mem:read_u8(0x020016)
            local full_pc = (pc_bnk * 0x10000) + pc_lo
            print(string.format("MAMEDBG-SNAP S=0x%04X PC=0x%06X",
                                s_val, full_pc))
            -- Dump the entire bank-0 stack window from S+1 up to the
            -- program-entry SP ($01FF).  Multi-frame `bt` walks several
            -- parent frames upward, each consuming `frameSize + 3`
            -- bytes; 64 bytes was enough for the topmost frame only.
            -- Capping at $01FF keeps the dump bounded and avoids
            -- reading past the user stack into bank-0 hardware
            -- registers / soft switches that would surface as
            -- $C000-page side-effects.
            local stack_top = 0x01FF
            for addr = s_val + 1, stack_top do
                local v = mem:read_u8(addr)
                print(string.format("MAMEDBG-STACK addr=0x%06X val=0x%02X",
                                    addr, v))
            end
            -- Dump each user-requested watch.
            for _, w in ipairs(WATCHES) do
                local addr, n = w[1], w[2]
                for ofs = 0, n - 1 do
                    local v = mem:read_u8(addr + ofs)
                    print(string.format("MAMEDBG-WATCH addr=0x%06X val=0x%02X",
                                        addr + ofs, v))
                end
            end
            mem:write_u16(0x020010, 0)
            snapshotted = true
        end
    end
end)
"""


def buildReplLuaScript(bin_path, load_at, bp_pcs, watch_regions,
                       start_pc, exit_frame):
    """Build a MAME autoboot Lua script for one REPL run.

    bp_pcs:        list of int (24-bit PCs) — breakpoints to install.
    watch_regions: list of (addr, length) tuples — per-symbol memory
                   dumps stamped at the first BP hit.
    """
    bp_list = ", ".join(f"0x{p:06x}" for p in bp_pcs)
    watch_list = ", ".join(f"{{0x{a:06x}, {n}}}" for a, n in watch_regions)
    return REPL_LUA_TEMPLATE.format(
        bin_path   = bin_path,
        load_at    = load_at,
        start_pc   = start_pc,
        bp_list    = bp_list or "",
        watch_list = watch_list or "",
        exit_frame = exit_frame,
    )


# Regex for snapshot/watch/stack lines emitted by the REPL Lua script.
SNAP_RE  = re.compile(r"MAMEDBG-SNAP\s+S=0x([0-9A-Fa-f]+)\s+PC=0x([0-9A-Fa-f]+)")
WATCH_RE = re.compile(r"MAMEDBG-WATCH\s+addr=0x([0-9A-Fa-f]+)\s+val=0x([0-9A-Fa-f]+)")
STACK_RE = re.compile(r"MAMEDBG-STACK\s+addr=0x([0-9A-Fa-f]+)\s+val=0x([0-9A-Fa-f]+)")


class ReplState:
    """All persistent state across REPL commands."""

    def __init__(self, args):
        self.args = args
        # Map: address -> symbol name (binary-searchable by funcAt)
        self.syms = pc2line.loadMapSymbols(args.map)
        # DWARF: line table + DIE trees (parsed once, reused)
        self.sectionPayloads = pc2line.loadSidecarSectionsAll(args.dwarf)
        self.cus             = pc2line.parseAllCus(self.sectionPayloads)
        self.lineTable       = pc2line.buildTable(args.dwarf)
        # Per-function frame records (sorted) — used by `bt` to walk
        # parent JSL frames.  Empty if the sidecar predates the
        # W65816AsmPrinter frame-record emission (older builds /
        # hand-written assembly objects); `bt` falls back to the
        # single-frame walk in that case.
        self.frameRecords = pc2line.loadFrameRecords(args.dwarf)
        # Breakpoints: list of (pc, label) - label is the original spec
        self.breakpoints = []
        # Watches: dict {symbol: (addr, length)}.  Length picked from
        # the symbol's DWARF type when available, else fall back to 2.
        self.watches = {}
        # Last snapshot — populated after a run.  Empty until first run.
        self.lastSnap = None        # {"pc": int, "sp": int}
        self.lastWatchBytes = {}    # {addr: byte} (last run only)
        self.lastStackBytes = {}    # {addr: byte} (last run only)

    def resolveSpec(self, spec):
        """Resolve `FUNC`, `FILE:LINE`, or `0xADDR` to a 24-bit PC.
        Returns (pc, label) or (None, error_msg).
        """
        spec = spec.strip()
        # Hex address?
        if spec.lower().startswith("0x"):
            try:
                return (int(spec, 16), spec)
            except ValueError:
                return (None, f"invalid hex: {spec!r}")
        # File:line?
        if ":" in spec:
            file_part, line_part = spec.rsplit(":", 1)
            try:
                want_line = int(line_part)
            except ValueError:
                return (None, f"invalid line: {line_part!r}")
            # Prefer the smallest-PC entry on the requested line so the
            # bp lands on the statement's first instruction, not a
            # later trailing entry.
            best = None
            for pc, fidx, ln, ft in self.lineTable:
                if ln != want_line:
                    continue
                if 0 < fidx <= len(ft):
                    fname = os.path.basename(ft[fidx - 1])
                else:
                    fname = "?"
                # Match if fname matches OR fname is "?" (DWARF5
                # file_idx=0 path means "the CU's primary file" — we
                # treat that as a wildcard match for the user-supplied
                # file name).
                if fname == file_part or fname.endswith(file_part) \
                   or fname == "?":
                    if best is None or pc < best[0]:
                        best = (pc, fname)
            if best is not None:
                return (best[0], f"{best[1]}:{want_line}")
            return (None, f"no DWARF line entry for {spec!r}")
        # Bare symbol name — lookup in map.
        for addr, sym in self.syms:
            if sym == spec:
                return (addr, sym)
        return (None, f"symbol {spec!r} not in map")

    def symbolSize(self, symname):
        """Best-effort size of a global symbol's storage (in bytes).

        Looks up DW_TAG_variable DIEs across all CUs.  Returns the
        resolved type's byte size, or None if not findable.  Falls back
        to caller-default (2) when None.
        """
        for cu in self.cus:
            if cu.root is None:
                continue
            for die in self._iterDies(cu.root):
                if die.tag != pc2line.DW_TAG_variable:
                    continue
                nm = pc2line.dieName(cu, die)
                if nm != symname:
                    continue
                tref = die.getRaw(pc2line.DW_AT_type)
                if tref is None:
                    return None
                target = pc2line._findDieByOffset(cu, tref[0])
                return self._typeByteSize(cu, target)
        return None

    def _iterDies(self, die):
        yield die
        for ch in die.children:
            yield from self._iterDies(ch)

    def _typeByteSize(self, cu, die):
        """Walk a type DIE chain, return byte size or None."""
        if die is None:
            return None
        seen = set()
        cur = die
        while cur is not None and cur.offset not in seen:
            seen.add(cur.offset)
            tag = cur.tag
            # Base / structure / union / enum types carry DW_AT_byte_size.
            bs = cur.getRaw(0x0b)   # DW_AT_byte_size
            if bs is not None:
                return bs[0]
            if tag == pc2line.DW_TAG_pointer_type:
                # 24-bit byte addresses are stored as 4-byte ptr32 by
                # default in our ABI; default-on Layer 2 builds use 4-byte
                # ptrs.  Fall back to addr_size if recorded.
                return cu.addr_size
            if tag in (0x26, 0x35, 0x37, 0x38):
                # const/volatile/restrict/typedef — follow.
                t = cur.getRaw(pc2line.DW_AT_type)
                if t is None:
                    return None
                cur = pc2line._findDieByOffset(cu, t[0])
                continue
            if tag == 0x01:        # DW_TAG_array_type
                t = cur.getRaw(pc2line.DW_AT_type)
                if t is None:
                    return None
                elem = self._typeByteSize(cu,
                    pc2line._findDieByOffset(cu, t[0]))
                if elem is None:
                    return None
                # Find first subrange child for count.
                for ch in cur.children:
                    if ch.tag == 0x21:    # DW_TAG_subrange_type
                        ub = ch.getRaw(0x2f)  # DW_AT_upper_bound
                        if ub is not None:
                            return elem * (ub[0] + 1)
                return None
            # Other tags — give up.
            return None
        return None

    def typeStrOfSymbol(self, symname):
        """Return a printable type string for a global symbol, or '?'."""
        for cu in self.cus:
            if cu.root is None:
                continue
            for die in self._iterDies(cu.root):
                if die.tag != pc2line.DW_TAG_variable:
                    continue
                nm = pc2line.dieName(cu, die)
                if nm == symname:
                    return pc2line.varTypeStr(cu, die)
        return "?"


def replLaunchMame(state, bp_pcs, start_pc, watch_regions, seconds=4):
    """Launch one MAME run with the queued breakpoints + watches.

    Returns the captured stdout/stderr text.  Parses MAMEDBG-SNAP,
    MAMEDBG-WATCH, MAMEDBG-STACK lines into state.lastSnap +
    state.lastWatchBytes + state.lastStackBytes.
    """
    lua = buildReplLuaScript(state.args.bin, state.args.load_at,
                             bp_pcs, watch_regions,
                             start_pc=start_pc,
                             exit_frame=240)
    with tempfile.NamedTemporaryFile("w", suffix=".lua",
                                     delete=False) as lf:
        lf.write(lua)
        lua_path = lf.name
    try:
        out = runMame(lua_path, seconds=seconds, debug_flag=True)
    finally:
        try:
            os.unlink(lua_path)
        except OSError:
            pass

    # Parse snapshot lines.
    state.lastSnap = None
    state.lastWatchBytes = {}
    state.lastStackBytes = {}
    bps = []
    for ln in out.splitlines():
        m = BP_RE.search(ln)
        if m:
            bps.append({
                "pc": int(m.group(1), 16),
                "a":  int(m.group(2), 16),
                "x":  int(m.group(3), 16),
                "y":  int(m.group(4), 16),
                "s":  int(m.group(5), 16),
                "db": int(m.group(6), 16),
            })
        m = SNAP_RE.search(ln)
        if m:
            state.lastSnap = {
                "sp": int(m.group(1), 16),
                "pc": int(m.group(2), 16),
            }
        m = WATCH_RE.search(ln)
        if m:
            state.lastWatchBytes[int(m.group(1), 16)] = int(m.group(2), 16)
        m = STACK_RE.search(ln)
        if m:
            state.lastStackBytes[int(m.group(1), 16)] = int(m.group(2), 16)
    state.lastBps = bps
    return out


def replPrintWhere(state):
    """Print PC -> source line for the last snapshot."""
    if state.lastSnap is None:
        print("  no snapshot yet — `run` first")
        return
    pc = state.lastSnap["pc"]
    sp = state.lastSnap["sp"]
    row = pc2line.query(state.lineTable, pc)
    func = pc2line.funcAt(state.syms, pc)
    if row is None:
        print(f"  PC=0x{pc:06x} (no DWARF line) FUNC={func} S=0x{sp:04x}")
    else:
        _, fname, ln = row
        print(f"  PC=0x{pc:06x} FILE={fname} LINE={ln} FUNC={func} "
              f"S=0x{sp:04x}")


def _btPrintFrame(state, frame_no, pc, sp):
    """Print one bt frame line.  Pure formatting — no state mutation."""
    func = pc2line.funcAt(state.syms, pc)
    row = pc2line.query(state.lineTable, pc)
    if row is None:
        print(f"  #{frame_no} PC=0x{pc:06x} FUNC={func} "
              f"S=0x{sp:04x}")
    else:
        _, fname, ln = row
        print(f"  #{frame_no} PC=0x{pc:06x} {fname}:{ln} FUNC={func} "
              f"S=0x{sp:04x}")


# Maximum unwinder depth.  Real recursion can exceed this on the IIgs's
# tiny stack, but past 16 frames the user almost certainly wants the
# truncation hint rather than a wall of identical-looking entries.
BT_MAX_FRAMES = 16

# Initial program-entry SP — crt0 sets up the user stack at $01FF
# (empty-descending) and JSLs main().  Once `bt`'s walker sees S climb
# past this value, we've reached the root and stop without printing
# the bogus "frame above crt0" the rule would otherwise produce.
BT_ROOT_SP = 0x01FF


def replPrintBacktrace(state):
    """Walk the JSL return frame chain using the .debug_frame_w65816
    sidecar.  Each step decodes the caller's PC from the return-address
    triplet pushed by JSL (PCL/PCH/PBR at S+frameSize+1..+3) and the
    caller's S as `current_S + frameSize + rtlBytes`.

    Falls back to the single-frame walk if no frame records were loaded
    (e.g. the sidecar predates this section).  That matches the prior
    behaviour exactly — the test in scripts/probeReplSmoke.sh remains
    backward-compatible.
    """
    if state.lastSnap is None:
        print("  no snapshot yet — `run` first")
        return
    pc = state.lastSnap["pc"]
    sp = state.lastSnap["sp"]
    _btPrintFrame(state, 0, pc, sp)

    if not state.frameRecords:
        # Old sidecar — fall back to the single-frame return-address
        # peek (caller of the current function only).  Preserves the
        # behaviour shipped before the .debug_frame_w65816 section
        # existed; pre-existing smoke probes that depend on the
        # "frame #1 visible" invariant still pass against old DWARF.
        pcl = state.lastStackBytes.get((sp + 1) & 0xFFFF)
        pch = state.lastStackBytes.get((sp + 2) & 0xFFFF)
        pbr = state.lastStackBytes.get((sp + 3) & 0xFFFF)
        if pcl is None or pch is None or pbr is None:
            print("  #1 <return address not in captured stack window>")
            return
        ret_pc = (((pbr << 16) | (pch << 8) | pcl) + 1) & 0xFFFFFF
        ret_sp = (sp + 3) & 0xFFFF
        _btPrintFrame(state, 1, ret_pc, ret_sp)
        print("  (no .debug_frame_w65816 — only one frame available)")
        return

    # Modern path: walk up via per-function frame records.
    cur_pc = pc
    cur_sp = sp
    # First-frame guard: when MAME breaks AT a function entry, the
    # prologue hasn't executed yet, so S points just below the
    # caller's JSL triplet (no frame allocated).  Pass the frame
    # size as 0 for the first hop in that case.  Later hops always
    # have a fully-set-up frame since we're looking at the caller
    # which is mid-execution by definition.
    first_hop_at_entry = False
    rec0 = pc2line.frameAt(state.frameRecords, cur_pc)
    if rec0 is not None and rec0[0] == cur_pc:
        first_hop_at_entry = True
    for frame_no in range(1, BT_MAX_FRAMES + 1):
        rec = pc2line.frameAt(state.frameRecords, cur_pc)
        if rec is None:
            # PC outside any recorded function (e.g. hand-written
            # assembly with no .debug_frame_w65816 record).  Without
            # a frame size we can't safely climb past this point.
            print(f"  (no frame record for PC=0x{cur_pc:06x} — "
                  f"stopping)")
            return
        _pc_start, _pc_end, frame_sz, rtl = rec
        # Return-address triplet lives at cur_sp + frame_sz + 1..+3
        # *except* when we're stopped at the function's first byte
        # (the prologue hasn't allocated the frame yet), in which
        # case the triplet is at cur_sp + 1..+3.  See first_hop_at_entry.
        effective_frame_sz = 0 if (frame_no == 1 and first_hop_at_entry) \
                               else frame_sz
        ret_base = (cur_sp + effective_frame_sz) & 0xFFFF
        pcl = state.lastStackBytes.get((ret_base + 1) & 0xFFFF)
        pch = state.lastStackBytes.get((ret_base + 2) & 0xFFFF)
        pbr = state.lastStackBytes.get((ret_base + 3) & 0xFFFF)
        if pcl is None or pch is None or pbr is None:
            print(f"  (return triplet at 0x{ret_base+1:04x}.."
                  f"0x{ret_base+3:04x} not in captured stack window — "
                  f"stopping)")
            return
        ret_pc = (((pbr << 16) | (pch << 8) | pcl) + 1) & 0xFFFFFF
        # New S after the popped JSL triplet: same arithmetic as the
        # epilogue's RTL would do (S += 3).  rtl_bytes is reserved for
        # future inline JSR/RTS subroutines (2 bytes) — for the
        # current ABI all calls are JSL/RTL so rtl is always 3.
        ret_sp = (ret_base + rtl) & 0xFFFF
        # Stop once we've climbed past the initial program-entry SP —
        # that means we've returned out of main() into crt0 / GS/OS
        # Loader scaffolding, where the frame record doesn't apply.
        if ret_sp > BT_ROOT_SP:
            _btPrintFrame(state, frame_no, ret_pc, ret_sp)
            print(f"  (reached crt0 / program-entry frame "
                  f"S=0x{ret_sp:04x} > 0x{BT_ROOT_SP:04x})")
            return
        # Stop if the unwind made no progress (cycle or pathological
        # rtl-byte mismatch).  Pure defensive check; the constants
        # above keep the legitimate path monotonic.
        if ret_sp <= cur_sp:
            print(f"  (non-monotonic SP at frame #{frame_no} "
                  f"cur=0x{cur_sp:04x} new=0x{ret_sp:04x} — stopping)")
            return
        _btPrintFrame(state, frame_no, ret_pc, ret_sp)
        cur_pc = ret_pc
        cur_sp = ret_sp
    print(f"  (>{BT_MAX_FRAMES} frames — truncated)")


def replPrintSymbol(state, spec):
    """Decode a symbol's bytes from the last snapshot and print them
    per the symbol's DWARF type.  If the symbol hasn't been watched
    yet (or no run has happened), instruct the user to `run` first.
    """
    addr = None
    for a, s in state.syms:
        if s == spec:
            addr = a
            break
    if addr is None:
        print(f"  no such symbol: {spec!r}")
        return
    # Make sure it's queued as a watch for the next run.
    if spec not in state.watches:
        sz = state.symbolSize(spec)
        if sz is None or sz <= 0:
            sz = 2
        if sz > 64:
            # Truncate: large structs/arrays surface the first 64 bytes.
            sz = 64
        state.watches[spec] = (addr, sz)

    if state.lastSnap is None or not state.lastWatchBytes:
        print(f"  &{spec} = 0x{addr:06x} (watch queued — run to capture)")
        return

    addr_w, length = state.watches[spec]
    bytes_ = bytearray(length)
    have_all = True
    for i in range(length):
        b = state.lastWatchBytes.get(addr_w + i)
        if b is None:
            have_all = False
            break
        bytes_[i] = b
    type_str = state.typeStrOfSymbol(spec)
    if not have_all:
        print(f"  {spec}: ADDR=0x{addr:06x} TYPE={type_str} "
              f"(no snapshot bytes — run again to capture)")
        return
    decoded = _decodeBytes(type_str, bytes_)
    hex_dump = " ".join(f"{b:02x}" for b in bytes_)
    print(f"  {spec} : {type_str} = {decoded}")
    print(f"    ADDR=0x{addr:06x} BYTES=[{hex_dump}]")


def _decodeBytes(type_str, raw):
    """Best-effort C-value print for a small byte buffer.

    Recognises:
      - int/short/char (1/2/4 byte ints, little-endian)
      - unsigned variants
      - any "* " (pointer) type — print as hex address
      - struct/union — show raw hex (the caller already prints BYTES=)
    Floats are out of scope per the task; print bytes as hex.
    """
    ts = type_str.strip()
    if not raw:
        return "<empty>"

    # Pointer types -> print as hex address of the right width.
    if ts.endswith("*") or " *" in ts:
        if len(raw) >= 4:
            v = raw[0] | (raw[1] << 8) | (raw[2] << 16) | (raw[3] << 24)
            return f"0x{v & 0xFFFFFFFF:08x}"
        if len(raw) >= 2:
            v = raw[0] | (raw[1] << 8)
            return f"0x{v:04x}"
        return f"0x{raw[0]:02x}"

    # Integer base types.
    int_widths = {
        "char": 1, "signed char": 1, "unsigned char": 1,
        "_Bool": 1, "bool": 1,
        "short": 2, "short int": 2,
        "unsigned short": 2, "unsigned short int": 2,
        "int": 2, "unsigned int": 2, "signed int": 2,
        "long": 4, "long int": 4, "signed long": 4,
        "unsigned long": 4, "unsigned long int": 4,
        "long long": 4, "unsigned long long": 4,
    }
    signed_set = {"char", "signed char", "short", "short int",
                  "int", "signed int", "long", "long int",
                  "signed long", "long long"}
    if ts in int_widths:
        w = int_widths[ts]
        n = min(w, len(raw))
        v = 0
        for i in range(n):
            v |= raw[i] << (8 * i)
        if ts in signed_set:
            top = 1 << (8 * n - 1)
            if v & top:
                v = v - (1 << (8 * n))
        return f"{v} (0x{v & ((1 << (8*n)) - 1):0{2*n}x})"

    # struct / union / class — caller dumps raw bytes.
    if ts.startswith("struct ") or ts.startswith("union ") \
       or ts.startswith("class "):
        # Show u16 words as a partial decode hint (often the first
        # field is an integer the user wants to see).
        if len(raw) >= 2:
            first_u16 = raw[0] | (raw[1] << 8)
            return f"<{ts}; first u16 = 0x{first_u16:04x}>"
        return f"<{ts}>"

    # Array type — show first elements as best-effort integers.
    if "[" in ts and ts.endswith("]"):
        first = " ".join(f"0x{b:02x}" for b in raw[:8])
        return f"[{first}{', ...' if len(raw) > 8 else ''}]"

    return "<no decoder>"


def replInfoLocals(state):
    """Show formal_parameters + locals at the last snapshot PC."""
    if state.lastSnap is None:
        print("  no snapshot yet — `run` first")
        return
    pc = state.lastSnap["pc"]
    sp = state.lastSnap["sp"]
    cu, sub, locs = pc2line.localsAtPc(state.cus, pc, sp_value=sp)
    if sub is None:
        print(f"  no subprogram at PC=0x{pc:06x}")
        return
    sub_name = pc2line.dieName(cu, sub) or "<unnamed>"
    print(f"  in {sub_name!r} at PC=0x{pc:06x} S=0x{sp:04x}")
    if not locs:
        print("  (no formal_parameter / variable in scope)")
        return
    for name, ty, loc, _die in locs:
        if loc.kind == "memory":
            print(f"    {name} : {ty}    ADDR=0x{loc.addr:06x}")
        elif loc.kind == "register":
            if loc.dp_addr is not None:
                print(f"    {name} : {ty}    REG=DW{loc.reg_dw} "
                      f"ADDR=0x{loc.dp_addr:06x}")
            else:
                print(f"    {name} : {ty}    REG=DW{loc.reg_dw}")
        elif loc.kind == "value":
            print(f"    {name} : {ty}    VALUE=0x{loc.value:x}")
        else:
            print(f"    {name} : {ty}    UNSUPPORTED={loc.reason}")


def replNextLinePc(state, current_pc):
    """Return the PC of the DWARF line entry strictly after current_pc,
    or None if there isn't one (end of program / no DWARF).
    """
    # The line table is unsorted in source order; iterate to find the
    # smallest entry whose PC is strictly greater than current_pc.
    best = None
    for pc, _fidx, _ln, _ft in state.lineTable:
        if pc > current_pc:
            if best is None or pc < best:
                best = pc
    return best


def replLoop(state):
    """Run the REPL.  Reads commands from stdin, dispatches each one."""
    interactive_tty = sys.stdin.isatty()
    if interactive_tty:
        print("mameDebug REPL.  Type ? for help.")
    while True:
        try:
            if interactive_tty:
                line = input("(dbg) ")
            else:
                line = input()  # no prompt in batch mode (cleaner output)
        except EOFError:
            if interactive_tty:
                print()
            break
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        # Echo command in batch mode so the smoke test can diff output.
        if not interactive_tty:
            print(f"(dbg) {line}")
        cmd, _, rest = line.partition(" ")
        rest = rest.strip()
        if cmd in ("q", "quit", "exit"):
            break
        if cmd == "?" or cmd == "help":
            print(REPL_HELP)
            continue
        if cmd in ("break", "b"):
            if not rest:
                print("  usage: break <sym|file:line|0xADDR>")
                continue
            pc, label = state.resolveSpec(rest)
            if pc is None:
                print(f"  cannot resolve: {label}")
                continue
            state.breakpoints.append((pc, label))
            idx = len(state.breakpoints)
            print(f"  bp #{idx} at 0x{pc:06x} ({label})")
            continue
        if cmd in ("info",):
            if rest == "breakpoints":
                if not state.breakpoints:
                    print("  no breakpoints")
                else:
                    for i, (pc, lab) in enumerate(state.breakpoints, 1):
                        print(f"  #{i} 0x{pc:06x} ({lab})")
                continue
            if rest == "locals":
                replInfoLocals(state)
                continue
            print(f"  unknown info subcommand: {rest!r}")
            continue
        if cmd == "delete":
            try:
                idx = int(rest)
            except ValueError:
                print("  usage: delete <N>")
                continue
            if idx < 1 or idx > len(state.breakpoints):
                print(f"  no breakpoint #{idx}")
                continue
            del state.breakpoints[idx - 1]
            print(f"  deleted bp #{idx}")
            continue
        if cmd in ("run", "r", "continue", "c"):
            if not state.breakpoints:
                print("  no breakpoints set — nothing to break on")
                continue
            bp_pcs = [pc for pc, _ in state.breakpoints]
            # Decide start_pc.  Precedence (highest first):
            #   --from-start  -> LOAD_AT (run through crt0)
            #   --start-at    -> user-supplied entry point (FUNC or hex)
            #                    — set this to an *outer* caller of the
            #                    bp so the JSL frame chain is real and
            #                    `bt` can walk multiple frames.
            #   default       -> jump straight to the first bp (matches
            #                    --trace behaviour; produces a single
            #                    frame in `bt`).
            if state.args.from_start:
                start_pc = state.args.load_at
            elif state.args.start_at:
                spec = state.args.start_at
                try:
                    start_pc = int(spec, 0)
                except ValueError:
                    start_pc = None
                    for addr, sym in state.syms:
                        if sym == spec:
                            start_pc = addr
                            break
                    if start_pc is None:
                        print(f"  --start-at '{spec}' not in map; "
                              f"falling back to bp[0]")
                        start_pc = bp_pcs[0]
            else:
                start_pc = bp_pcs[0]
            watch_regions = list(state.watches.values())
            replLaunchMame(state, bp_pcs, start_pc, watch_regions,
                           seconds=state.args.seconds)
            if state.lastSnap is None:
                print("  WARN: no BP-HIT captured (timed out?)")
            else:
                replPrintWhere(state)
            continue
        if cmd in ("step", "s", "next", "n"):
            # Both map to "advance to next source line via DWARF" in
            # our snapshot-based model.  Requires a prior snapshot to
            # know "where we are".
            if state.lastSnap is None:
                # No prior snapshot: just do `run` (start of program).
                if not state.breakpoints:
                    print("  no breakpoints set — `break` first")
                    continue
                bp_pcs = [pc for pc, _ in state.breakpoints]
                start_pc = (state.args.load_at if state.args.from_start
                            else bp_pcs[0])
                replLaunchMame(state, bp_pcs, start_pc,
                               list(state.watches.values()),
                               seconds=state.args.seconds)
                if state.lastSnap is not None:
                    replPrintWhere(state)
                continue
            current_pc = state.lastSnap["pc"]
            next_pc = replNextLinePc(state, current_pc)
            if next_pc is None:
                print("  no next DWARF line entry — at end of program")
                continue
            print(f"  stepping to next DWARF line at 0x{next_pc:06x}")
            replLaunchMame(state, [next_pc], current_pc,
                           list(state.watches.values()),
                           seconds=state.args.seconds)
            if state.lastSnap is None:
                print("  WARN: step did not hit the bp (timed out?)")
            else:
                replPrintWhere(state)
            continue
        if cmd == "where":
            replPrintWhere(state)
            continue
        if cmd in ("bt", "backtrace"):
            replPrintBacktrace(state)
            continue
        if cmd in ("print", "p"):
            if not rest:
                print("  usage: print <symbol>")
                continue
            replPrintSymbol(state, rest)
            continue
        print(f"  unknown command: {line!r} (try ?)")
    return 0


def replMode(args):
    """Entry point for `--repl`."""
    state = ReplState(args)
    if args.break_at:
        # --break is interpreted as "queue this bp before reading any
        # interactive commands" — useful when scripting.
        pc, label = state.resolveSpec(args.break_at)
        if pc is None:
            print(f"mameDebug: --break {args.break_at!r}: {label}",
                  file=sys.stderr)
            return 2
        state.breakpoints.append((pc, label))
        print(f"  bp #1 at 0x{pc:06x} ({label}) [from --break]")
    return replLoop(state)


# ---- main ------------------------------------------------------------

def main():
    ap = argparse.ArgumentParser(
        description="GDB-style debugger front-end for W65816 + MAME")
    ap.add_argument("--bin", required=True, help="flat .bin image to load")
    ap.add_argument("--map", required=True, help="link816 .map")
    ap.add_argument("--dwarf", required=True, help="link816 --debug-out sidecar")
    ap.add_argument("--load-at", type=lambda s: int(s, 0), default=0x1000,
                    help="bank-0 load address (default 0x1000)")
    ap.add_argument("--break", dest="break_at", default=None,
                    help="breakpoint for --trace (FUNC or FILE:LINE).  "
                         "Default: 'main'")
    ap.add_argument("--seconds", type=int, default=4,
                    help="MAME simulated seconds (default 4)")
    ap.add_argument("--trace", action="store_true",
                    help="default-on smoke mode: set bp, capture one "
                         "BP-HIT, resolve via pc2line, exit 0")
    ap.add_argument("--repl", action="store_true",
                    help="interactive REPL.  Reads stdin commands "
                         "(break/run/step/next/where/bt/print/info/"
                         "delete/quit).  Each `run`/`step`/`next` "
                         "launches one MAME process.  `print`, `bt`, "
                         "and `where` decode the captured snapshot "
                         "and need no further MAME launch.")
    ap.add_argument("--from-start", action="store_true",
                    help="start execution at LOAD_AT (i.e. through "
                         "the crt0).  Default is to jump straight to "
                         "the bp target — required for crt0Gsos/Gno "
                         "binaries since their startup expects the "
                         "GS/OS Loader to have applied relocations.")
    ap.add_argument("--start-at", default=None,
                    help="override the initial PC: FUNC name or hex "
                         "address.  Default = the bp target.  Use to "
                         "set bp inside a deeper callee while still "
                         "starting from main() (so the JSL frame is "
                         "on the stack for --finish).")
    ap.add_argument("--finish", action="store_true",
                    help="trace + finish: also install a one-shot bp "
                         "at the breakpointed function's RTL return "
                         "address, prove the entry+return pair fires "
                         "end-to-end.  Drives the `finish`-command "
                         "primitive in the interactive shell.")
    ap.add_argument("--verbose", "-v", action="store_true",
                    help="dump full MAME output to stderr")
    args = ap.parse_args()
    if not os.path.exists(args.bin):
        print(f"mameDebug: missing --bin {args.bin}", file=sys.stderr)
        return 2
    if not os.path.exists(args.map):
        print(f"mameDebug: missing --map {args.map}", file=sys.stderr)
        return 2
    if not os.path.exists(args.dwarf):
        print(f"mameDebug: missing --dwarf {args.dwarf}", file=sys.stderr)
        return 2
    if args.trace:
        return traceMode(args)
    if args.repl:
        return replMode(args)
    return interactiveMode(args)


if __name__ == "__main__":
    sys.exit(main())
