Files
Joe MattielloandClaude Opus 5.5 9c5656e271 perf(dsp): build the untimed DSP loop without GDB code when nothing is armed (#1057)
* perf(dsp): build the untimed loop without GDB code when nothing is armed

DSPExecUntimed() is now compiled twice from dsp_exec_untimed.h: a plain
instance with no breakpoint test and no idle-skip gate, and an armed
instance used only while gdbArmedDSP is set.  DSPExec() picks one per
slice, alongside the existing timed/untimed split.

The armed instance keeps the breakpoint test where it was, after the IRQ
dispatch and the PC-escape bail, so a breakpoint on an ISR's first
instruction still fires.  Arming is sampled once per slice, as before.

Preprocessed, the plain loop is develop's loop with the GDB lines
removed and nothing else changed.

Refs #1054

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(debug): record the #1054 DSP loop specialisation and its layout caveat

The parked specialisation now ships, so the design doc's "parked" and
"~2% unexplained" text no longer describes the code.  Records the A/B/B/A
numbers.  It also records that code layout moves this loop by about +/-1%:
compiling the check out makes real games slower, and the port beats the
ceiling on identical C.  The claim stays narrow: yarc gains, games are
neutral.  It also notes that the DSP_HOOK control arm will now read
about 0.

Refs #1054

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(dsp): keep the local-RAM opcode fetch inside dsp_ram_8

The six fetch sites (both exec loops and the four delay-slot fetches)
read dsp_ram_8[off] and [off + 1] whenever dsp_pc was below base +
0x2000.  So an odd PC at base + 0x1FFF read one byte past the array.
cppcheck flagged it (arrayIndexOutOfBoundsCond) once the code moved
into a changed file.  The bound is now base + 0x2000 - 1, the same form
gpu.c already uses.  That one address now goes through DSPReadWord(),
which aligns.  Every other PC is byte-identical.

clang-tidy now skips src/jerry/dsp_exec_untimed.h the way it skips the
*_simd_* fragments: on its own it is only undeclared dsp.c statics.
Its code is still linted through dsp.c via HeaderFilterRegex.

Refs #1054

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-21 02:30:22 -04:00

38 KiB

GDB remote serial protocol stub — design

Issue #652. Approved design, not yet implemented (2026-08-26).

Purpose

Expose the emulated Jaguar to a real debugger over GDB's Remote Serial Protocol (RSP), so that homebrew developers can set breakpoints, inspect and modify registers and memory, and single-step the 68000, GPU and DSP — from gdb, lldb, VS Code, or any other RSP client they already use.

This is also this project's settled answer to "should we have a scripting system". GDB ships a complete Python scripting environment on top of breakpoints and memory access, which is the entire feature list BigPEmu's bespoke C-to-bytecode VM sells. Implementing a well-documented wire protocol gets us that capability permanently, with the scripting engine maintained by someone else. See #370, which rejected a VM twice on scope grounds, and #637.

Decisions settled before design

These were decided with the maintainer and are not open for re-litigation inside the implementation.

  1. Shipped in every build, off by default, activated by a core option that opens a TCP listener on demand — not a dev-only facility gated behind TEST_EXPORTS=1 like vjtrace. Rationale: the audience is homebrew developers, and requiring them to build the core themselves excludes most of them. Consequence: the design must prove near-zero cost when disabled.
  2. A breakpoint hit blocks inside retro_run(). The machine freezes exactly at the fault with registers, memory and cycle position coherent. The frontend UI freezing is accepted as the cost; it is mitigated, not designed away.
  3. All three processors are exposed. The 68000 as a native GDB target; the GPU and DSP as additional GDB threads described by custom XML target descriptions, with disassembly served by our own tooling through monitor commands, because GDB has no backend for the Jaguar RISC ISA and never will.
  4. No emulated memory is ever patched to implement a breakpoint (see "Breakpoint detection" below).

What already exists, and why this is smaller than it looks

The three hardest-sounding pieces are already in the tree:

Need Already present
Per-instruction 68K hook M68KInstructionHook(), src/core/jaguar.c:364 — already called after every instruction in shipped builds for #542's traceback ring. M68K_HOOK_FUNCTION is already defined in m68kinterface.h:101.
68K halt/resume M68KDebugHalt() / M68KDebugResume(), m68kinterface.c:90 — set and clear SPCFLAG_DEBUGGER. Already wired to six memory-breakpoint sites in jaguar.c.
Memory watchpoints Exported "breakpoint on memory access" vars, jaguar.c:356, checked at six sites in the memory path.
RISC single-step Real hardware: G_CTRL/D_CTRL bit 3 (SINGLE_STEP). gpu.c and dsp.c already reason about a single-step-parked core's interrupt behaviour.
RISC disassembly test/tools/gpu_disasm_dump, test/tools/disasm_gpu_isr.py, test/tools/bios_disasm.py.
Cross-platform TCP src/jerry/jlink_tcp.c — netlink's transport, already built on every target in Makefile.common.

The genuinely new code is the RSP protocol layer and the target glue.

Architecture

                    gdb / lldb / VS Code
                            |
                       TCP 127.0.0.1
                            |
   +------------------------v-------------------------+
   |  src/debug/gdbsock.c   listener, one client,      |
   |                        non-blocking accept        |
   +------------------------+-------------------------+
                            |
   +------------------------v-------------------------+
   |  src/debug/gdbstub.c   RSP framing: $..#cs, ack/  |
   |                        nak, no-ack mode, dispatch |
   +------------------------+-------------------------+
                            |
   +------------------------v-------------------------+
   |  src/debug/gdbtarget.c register file per target,  |
   |                        memory r/w, bp table,      |
   |                        step/continue, halt loop   |
   +----+-----------------+------------------+--------+
        |                 |                  |
   M68KInstructionHook  GPUExec()         DSPExec()
   (jaguar.c:364)       (tom/gpu.c)       (jerry/dsp.c)

Three new files under a new src/debug/ directory, plus a handful of call sites. Nothing existing is refactored.

Why a new directory

src/debug/ rather than src/core/ because the stub depends on TOM, JERRY, the 68K and the memory map, i.e. on everything — putting it under any one subsystem's directory would misrepresent it. It also keeps the protocol layer unit-testable without linking the emulator (see Testing).

Target and thread model

GDB threads map to processors:

GDB thread Processor Register file
1 68000 D0-D7, A0-A7, PC, SR — native m68k description, GDB knows it
2 GPU RISC (TOM) R0-R31, PC, FLAGS, plus the memory-mapped control registers
3 DSP RISC (JERRY) R0-R31, PC, FLAGS, plus its control registers

Hg / Hc select one. qfThreadInfo enumerates only thread 1 (issue #936): gdb reads exactly one target description per connection and applies it to every thread, so offering the GPU/DSP (a 34-register layout) next to the 68K (18 registers) made info threads fail with "Remote 'g' packet reply is too long". Hg2/Hg3 and the RISC descriptions remain for raw RSP clients; stock gdb reaches the RISCs through monitor regs|disasm|halt gpu|dsp. Stop replies are therefore always T05thread:1;: a GPU/DSP halt is reported as a stop of thread 1 (the 68K is frozen with the rest of the machine), preceded by an O console line ("GPU halted at $F03010 -- see monitor regs gpu"). A raw client that used Hg2 + Z0 and relied on thread:2 in the stop reply now learns which core halted only from that O line (behaviour change; no in-tree client depended on it).

Thread 1 is served the engine's built-in m68k description (GDBTarget68KXML in gdbstub.c): <architecture>m68k</architecture> and the org.gnu.gdb.m68k.core registers in exactly the g layout (d0-d7, a0-a5, fp, sp, ps, pc), no FP feature. This replaced "no description, GDB applies its own m68k", which cannot work: the stub advertises qXfer:features:read+, so an empty answer to target.xml makes stock gdb abort ("conflicting enabled responses"). A description cannot declare byte order, so set endian big is still the user's job. Source- level debugging with DWARF does not come for free — see Open Question 2, answered during Phase 1 implementation: rln emits classic mc68k COFF, not ELF/DWARF, and its one debug-info flag (-g) produced byte-identical output to a build without it on a real test case. The honest claim is symbol-level (global symbol addresses via rln -m's load map), not source-level.

Threads 2 and 3 are served a custom description via qXfer:features:read, defining an org.atari.jaguar.risc feature with the 32 general registers and the flags (raw clients only, see above). GDB cannot disassemble them — no GDB backend for this ISA exists. That gap is filled by monitor commands rather than pretended away.

monitor commands

GDB forwards monitor <text> verbatim as qRcmd. The stub implements:

monitor disasm <addr> [count]   RISC disassembly via our own disassembler
monitor regs [gpu|dsp]          full control-register dump, decoded
monitor halt <gpu|dsp|68k>      halt one processor without a breakpoint
monitor trace                   dump the #542 68K PC traceback ring
monitor watch <addr> [r|w|rw]   memory watchpoint (also via GDB's own Z2/Z3/Z4)

monitor trace is deliberately included: the traceback ring is already maintained unconditionally, so exposing it costs nothing and is exactly what a developer wants after a wild jump.

Breakpoint detection — the armed-flag gate

Three approaches were considered.

Rejected: patch the instruction. The classic stub technique — overwrite the target instruction with a trap opcode — costs nothing when unused, but the patched bytes live in emulated memory. They would be captured by savestates, which is precisely the class of bug #400 was; they interact badly with self-modifying code; and cart ROM versus RAM need different handling. Rejected on the savestate hazard alone.

Rejected: check a breakpoint table every instruction. Simple and safe, but an unconditional per-instruction cost in every shipped build on every target, including the Raspberry Pi and classic ARM builds tuned in #567. This is the mistake vjtrace made and then had to gate behind VJ_TRACE.

Chosen: per-target armed counters, no memory modification.

/* src/debug/gdbtarget.h -- read by the hot paths, written only by the stub. */
extern int gdbArmed68K;
extern int gdbArmedGPU;
extern int gdbArmedDSP;

Each is the count of armed breakpoints for that processor. The hot-path check is one load of a global that is in cache and one never-taken branch:

/* in M68KInstructionHook(), after the existing traceback ring write */
if (gdbArmed68K && GDBCheckPC(GDB_TGT_68K, m68kPC))
   GDBHalt(GDB_TGT_68K, GDB_STOP_BREAKPOINT);

Per-target counters matter: a developer debugging 68K code must not make GPUExec() pay for it.

When a counter is nonzero, GDBCheckPC() consults a 256-entry direct-mapped cache indexed by (pc >> 1) & 0xFF holding the full PC, so the common case is one load and one compare; a miss falls through to a linear scan of the breakpoint array (breakpoint counts are small — the array is capped at 64 per target).

Why this should be free, with evidence

The vjtrace investigation measured its 6-8% regression and attributed it specifically to the per-instruction ring buffer writes, not to the hook mechanism: a plain function call and an inlined macro writing the ring directly measured within 0.02% of each other across 11 paired interleaved samples. The cost was doing work, not deciding whether to. A predicted-not-taken branch on a hot global is the cheap half of that finding.

This is a hypothesis and the implementation must prove it. See Testing.

Watchpoints

GDB's Z2/Z3/Z4 (write/read/access watchpoints) map onto the existing exported memory-breakpoint variables at jaguar.c:356, already checked at six sites. Those sites currently call M68KDebugHalt(); they gain a branch to GDBHalt() when the stub owns the watchpoint. No new instrumentation.

The halt loop and retro_run()

When GDBHalt() fires, the call stack is retro_run() -> JaguarExecuteNew() -> ... -> M68KInstructionHook(). The stub does not unwind. It enters a loop that blocks on the socket, services RSP packets, and returns only on c (continue), s (step), vCont, or client disconnect. Because we never return, all emulator state is frozen exactly where it stopped — which is the whole reason this halt model was chosen.

Consequences and mitigations:

  • The frontend UI freezes and audio starves. Accepted. Mitigated by a SET_MESSAGE_EXT banner shown when the stub is enabled and again when a client attaches (both are moments we can still reach the frontend), warning that halts will freeze the frontend. A log line is emitted at every halt.
  • A user who forgets a stub is attached sees an apparent hang. Mitigated by the optional virtualjaguar_gdb_halt_timeout core option: after N seconds halted with no client activity, auto-continue and log loudly. Default is off, because silently resuming a debugged machine is worse than a freeze for the audience this is built for.
  • Client disconnect while halted resumes immediately and disarms every breakpoint. A dead debugger must never leave the machine wedged.
  • retro_reset() / retro_unload_game() arriving while halted cannot happen — the frontend is blocked in retro_run(). Nothing to handle.

Single-step

s / vCont;s for thread 1 uses the 68K's existing SPCFLAG_DEBUGGER path. For threads 2 and 3 the stub sets a one-shot internal breakpoint at "any next instruction" by arming the target's counter and having GDBCheckPC() return true unconditionally for one instruction, then re-halting. The hardware SINGLE_STEP bit is deliberately not used: it is guest-visible state that a game can read, and writing it would be an observable perturbation.

Security

The stub accepts a socket connection that can read and write emulated memory. Treated accordingly:

  • Binds 127.0.0.1 only. Never 0.0.0.0, and no core option to change that. Anyone needing remote access can forward a port deliberately.
  • One client at a time. A second connection is accepted and immediately closed, so a stale client cannot be silently displaced.
  • No GDB file-I/O extension. vFile and F packets are not implemented — that is the part of RSP that reaches the host filesystem, and we have no use for it.
  • Every address is bounds-checked against the emulated memory map before any read or write. A malformed or hostile M/X packet must never write outside emulated space. This is the single most important invariant in the implementation and gets direct unit tests.
  • Packet sizes are bounded and parsed without unbounded allocation. The RSP parser is the attack surface; it is written to be boring.

Savestate and determinism guarantees

  • The stub adds no savestate fields and requires no version bump. The breakpoint table, the armed counters and the socket are host-side only.
  • With the stub enabled but no breakpoint armed, emulation is bit-identical to the stub being disabled. This is a test, not a claim (see Testing).
  • Register and memory writes issued by GDB do change emulated state — that is the user explicitly asking for it. A savestate taken after such a write is legitimately different, and this is documented rather than guarded.
  • Run-ahead: a second core instance would fail to bind the port. Bind failure is logged once and the stub stays disabled for that instance rather than retrying. Debugging with run-ahead active is not supported and says so.

Portability

src/jerry/jlink_tcp.c already provides working TCP on every target in Makefile.common, including Windows, so the stub reuses that abstraction rather than inventing one. On any target where the listener cannot be created, the core option is hidden via SET_CORE_OPTIONS_DISPLAY and the stub reports disabled — it never fails a content load.

C89/GNU89 throughout, like the rest of the core: declarations at the top of every block, no C99.

Core options

Key Values Default
virtualjaguar_gdb_stub disabled / enabled disabled
virtualjaguar_gdb_port 2345 / 2346 / 2347 / 3333 2345
virtualjaguar_gdb_wait disabled / enabled — halt at boot until a client attaches disabled
virtualjaguar_gdb_halt_timeout off / 30s / 60s / 300s off

virtualjaguar_gdb_wait matters more than it looks: debugging a boot-time fault is impossible if the machine has already run past it before you attach.

Testing

Five layers, in increasing cost:

  1. Protocol unit tests, no emulator. test/tools/test_gdbstub_proto.c links gdbstub.c alone against a fake transport: packet framing, checksum validation, escape handling, qSupported negotiation, no-ack mode, and malformed-packet rejection. This is where the bounds-checking invariant is proven directly, including adversarial M/X addresses.

  2. Target glue tests. Register read/write round-trips for all three processors, breakpoint arm/disarm counting, and the direct-mapped cache's behaviour on collisions.

  3. Determinism. Savestate digest identical across three configurations: stub disabled; stub enabled and unattached; stub enabled, attached, zero breakpoints armed. Any difference is a bug in the gate.

  4. Perf, and this one gates the design. A/B/B/A interleaved benchmark of a build with the hooks present against a build with them #ifdef'd out. The armed-flag hypothesis is only validated if the difference is within noise on an idle machine. Per this repo's documented history, measurements must be interleaved, never sequential, and uptime checked first — a sequential "-O3 +12.5%" once flipped to -11.7% when interleaved. If the cost is not within noise, the shipped-by-default decision must be revisited rather than the number explained away.

    Measured, twice, and the first result was wrong (2026-08-27).

    A re-run found the hooks cost ~4.5%, not nothing:

    Run median delta best-run delta Mann-Whitney
    1 +4.1% +4.5% z=-5.20, p=0.0000
    2 +4.7% +4.5% z=-4.97, p=0.0000

    Both via test/tools/opt_ab.sh 'VJ_GDB_STUB_DISABLE_HOOKS=0' 'VJ_GDB_STUB_DISABLE_HOOKS=1' 12 (12 quartets, 24 samples per arm), host load average 5-7 -- not idle, but interleaving is the defense against exactly that, and the best-run figures (least contaminated) agree exactly across both runs.

    The earlier measurement recorded here claimed +1.9% at p=0.50, "within noise". It also described the host as idle while reporting a load average of 9-10, which cannot both be true. The likely mechanism for the null result: VJ_GDB_STUB_DISABLE_HOOKS was a local Makefile edit that was never added to BUILD_AXES and was reverted after the run, so it is plausible both arms were the same binary. opt_ab.sh prints a per-arm binary hash precisely so this can be checked; the re-runs above show two distinct hashes (c861b6628cdf vs 236f3467e128), identical across both runs.

    The switch is now a permanent, documented make variable listed in BUILD_AXES, so the comparison is reproducible and cannot silently benchmark one binary against itself.

    Consequence: the armed-flag hypothesis does not hold as stated, and per this document's own rule that revisits shipping the stub enabled by default. Before reversing that decision, the cost should be localised -- VJ_GDB_STUB_DISABLE_HOOKS gates two different things, and they have very different fixes:

    1. the per-instruction gdbArmed* PC check in M68KInstructionHook()/GPUExec()/DSPExec() -- the thing the hypothesis was actually about; and
    2. if (gdbArmed{GPU,DSP}) idleSkipActive = 0; on the idle-skip path. Idle-loop fast-forward is where this core spends most of its budget (#569 measured 99.7% of budget in idle loops for some titles), so a branch there plausibly dominates the 4.5%.

    Fully attributed (2026-08-27). Each component was measured on its own control arm (all test/tools/opt_ab.sh ... 12, 24 samples/arm, host load 5-7):

    Component Cost Significance
    68K hook (M68KInstructionHook) ~0% p=0.8366
    Idle-skip gating (idleSkipActive = 0) ~0% p=0.2238 (re-measured, see below)
    GPU per-instruction check 1.8% fixed, see below
    DSP per-instruction check ~2.3-2.7% p=0.0000

    Correction (2026-08-28): the idle-gating row above was originally vacuous, and has been re-measured. VJ_GDB_STUB_DISABLE_IDLE_GATE was inert as shipped -- its ifeq carried a backslash-continuation inside the $(...), so make read one variable reference named VJ_GDB_STUB_DISABLE_IDLE_GATE VJ_GDB_STUB_DISABLE_68K_HOOK VJ_GDB_STUB_DISABLE_DSP_HOOK, which does not exist. It expanded empty, -DVJ_GDB_STUB_DISABLE_IDLE_GATE was never defined for any value of any of those three variables, and both A/B arms compiled to a byte-identical binary (de95224107feb3c2 on both, against 81e65904a50a39e3 / 6a6a365e09e8dfbb / 9f3f63421f1a0275 for the three arms that did work). The original p=0.4897 was therefore one binary measured against itself -- the same failure mode this section was written to correct in #709, one level down.

    After fixing the conditional the two arms differ (8e2a80ecedc0 vs f28825134333) and the re-run agrees with the original conclusion on real evidence: 12 quartets, 24 samples/arm, median delta -1.4%, z=+1.22, p=0.2238 -- under the noise floor, no effect. So idle-skip gating really is free, and it is not a candidate for the residual below. Host load 6-8 (not idle; the A/B/B/A interleave is what makes the number usable, not a quiet box).

    The three per-processor arms were unaffected -- opt_ab.sh runs make clean between arms, so the two axes missing from BUILD_AXES (..._68K_HOOK, ..._DSP_HOOK, both added there by the same fix) could not have mixed objects into those runs.

    Fix applied: cache the armed flag in a slice-entry local, exactly as #532 does for pipeTiming/riscScale in the same function. The globals were being reloaded GOT-indirect once per emulated instruction because the opcode call is opaque -- the branch was never the problem, the reload was. That took 4.5% -> 2.7%, and the GPU's share to zero.

    ~2.0% remains, in the DSP loop, and is NOT explained. Loop specialisation was tried (body extracted to a header, included into a plain loop and an armed loop, so the non-debug path contains no GDB code at all): it recovered only a further 0.7% on the DSP and zero on the GPU. Parked on branch perf/gdb-dsp-loop-specialisation (digest-verified, C89-clean) rather than merged -- 0.7% did not justify an extracted-body header in the emulator's hottest function.

    Update (issue #1054, 2026-10): the DSP half is now specialised. By October develop had split DSPExec() into timed and untimed loops, and the untimed loop is now built twice from src/jerry/dsp_exec_untimed.h: a plain instance with no GDB code, and an armed instance that DSPExec() enters only while gdbArmedDSP is set. Measured on an M4 Max, A/B/B/A, load 2-4 unless noted:

    Arms ROM Median Best-run p
    develop vs DSP check compiled out (the ceiling) yarc +2.7% +2.3% 0.0003
    develop vs port yarc +2.6% +3.5% 0.0006
    develop vs port (repeat, 5-min load ~10) yarc +1.6% +3.3% 0.0092
    develop vs port, both DSP-armed (load rose 2 -> 17) yarc +2.5% +0.3% 0.1078
    develop vs port Tempest 2000 -0.2% +0.2% 0.7415
    develop vs port Doom -0.8% -0.4% 0.6801
    develop vs ceiling Tempest 2000 -1.0% -1.3% 0.0007
    develop vs ceiling Doom -0.6% -1.3% 0.0168
    ceiling vs port yarc +1.1% +0.9% 0.0018

    Read the last three rows before quoting a cause. Compiling the check out makes real games about 1% slower, and the port beats the ceiling on yarc although its plain loop is the same C. Code layout moves this loop by about +/-1%, the same size as the check. So the claim is narrow: the default DSP path carries no GDB code, yarc gains, and games are neutral. A "the check cost 2%" reading is not supported.

    One consequence for the control arms: after this change VJ_GDB_STUB_DISABLE_DSP_HOOK=1 compares two nearly identical plain loops, so the DSP row of a future attribution run should read about 0. That does not mean the arm is broken.

    Next step is real-hardware profiling, not more host measurement. Every number here comes from an arm64 Mac that never dropped below load 5. The residual should be re-attributed on a real target (tvOS/A-series, RPi) before any further surgery: the per-processor control arms VJ_GDB_STUB_DISABLE_{HOOKS,IDLE_GATE,68K_HOOK,DSP_HOOK} exist for exactly that and are all listed in BUILD_AXES, so the attribution can be repeated there in four runs without re-deriving any of this. Check the two per-arm binary hashes opt_ab.sh prints before believing any null result -- that is what the inert-gate correction above turned on, and it is the second time on this feature that identical arms produced a confident-looking p-value.

    Shipped-by-default stands for now at a measured ~2% cost, deliberately, pending those numbers. (Since #1054 the default DSP path carries no GDB code at all; see the update above.)

    This is not merely a stand-in for gdb being unavailable. Phase 1's GDBHandlePacket never sent the low-level +/- acknowledgement byte a real GDB client requires for every packet before QStartNoAckMode is negotiated (and that negotiation itself needs one such ack to complete). Phase 1's gdb_attach_probe.py passed without noticing this because it never checked for an ack — a real m68k-elf-gdb would have stalled on its very first qSupported.

    Phase 2 closed that gap and extended this layer: test/tools/gdb_attach_probe.py now verifies a + precedes every reply in ack mode, and that acks stop arriving after QStartNoAckMode — including the off-by-one (the OK reply to QStartNoAckMode itself is still acked; only packets after it are not). test/tools/gdb_breakpoint_probe.py is new: it launches the core headlessly (via test/tools/gdb_determinism_probe as a free-running host process), halts it at boot (virtualjaguar_gdb_wait), injects a single real 68K instruction (BRA.S *, a branch-to-self loop) into scratch RAM via M, redirects PC to it via G, arms a Z0 there, continues, and asserts the stop reply names thread 1 and a fresh register read shows PC still at that exact address — a real breakpoint actually halting real (if synthetic) execution, not just a protocol-level OK reply. The gdb_wait halt is load-bearing, not just convenient: an early version of this script picked an arbitrary "probably unused" RAM address without halting first, and the ROM's own boot code overwrote the injected instruction before the CPU fetched it, within a single frame — confirmed by reading the address back and finding the ROM's own data there instead.

    test/tools/gdb_reconnect_probe.py covers what a single-connection probe structurally cannot: ack mode is per-CONNECTION, not per-content-load. It attaches twice to one long-lived core — the first client negotiating QStartNoAckMode exactly as a real gdb/lldb does, then dropping — and asserts the second client's very first qSupported reply is acked again. Before GDBTargetResetState() cleared noAckMode, it wasn't: the flag stayed latched from the dead client and the second client's handshake blocked on an ack that never came (observed with lldb, then this repo's own gdb_attach_probe.py, against one running core).

    Issue #936 (stock-gdb compatibility) added three layers. The engine unit tests in test_gdbstub_proto (part of make test) pin the 68K target.xml (m68k architecture, 18 registers in g order, no RSP special characters), the 68K-only thread roster, D/vKill/k and the O-packet monitor streaming. test/tools/gdb_stock_compat_probe.py checks the same against the live core (target.xml for the real ops, Z2, monitor trace 1024 complete, detach/kill leave the emulator running and the listener open). test/tools/gdb_stock_gdb_check.sh drives a real m68k-capable gdb end to end with no workaround settings (skips when none is installed).

Out of scope

  • Tracepoints (QTDP and the rest of GDB's tracepoint machinery).
  • GDB file I/O (vFile), permanently — see Security.
  • Reverse debugging (bc/bs). Interesting given we have savestates, but it is a separate feature with its own design.
  • Debugging across savestate load. Loading a state while attached leaves breakpoints armed at addresses that may no longer mean anything; documented, not solved.
  • Non-stop mode. All three processors halt together. Halting the 68K while the GPU runs is not modelled.

Open questions

  1. Does the #581 toolchain include m68k-elf-gdb? Answered 2026-08-26, Phase 1 implementation: no. tools/jaguar-toolchain/PIN pins exactly five tools — rmac, rln, lyxass, pc_jagcrypt, new_bjl — and make jaguar-toolchain-build was actually run to confirm: the built tree (tools/vendor/jaguar-toolchain/) contains no gdb binary anywhere, and none of the five tools is a debugger. There is no supported source of a Jaguar-flavoured GDB in this repo's toolchain. Consequence: test layer 5 is permanently a scripted RSP client (test/tools/gdb_attach_probe.py) rather than a stopgap until gdb shows up, and the user-facing docs must tell developers to bring their own m68k-elf-gdb (a stock --target=m68k-elf GNU binutils/gdb build, e.g. from the m68k bare-metal toolchains distributed for classic Mac/Amiga/Atari ST development, works against a plain m68k architecture with no custom target description) rather than implying this repo provides one.

    Further verified 2026-08-27: no bottled m68k-elf-gdb exists on Homebrew either (brew search: aarch64-elf-gdb, arm-none-eabi-gdb, i386-elf-gdb, riscv64-elf-gdb, x86_64-elf-gdb are all real bottles; m68k-elf-gdb is not among them) — a developer has to build GDB from source with --target=m68k-elf themselves; upstream GDB does support the architecture, there is just no pre-built binary to point users at. Homebrew does bottle a genuinely separate, actively-maintained C toolchain for this target — m68k-elf-gcc (16.2.0) and m68k-elf-binutils (2.47) — but that is GCC+GNU ld, unrelated to this repo's rmac/rln assembler pipeline (#581); using it would mean writing C and building a new path from its ELF+DWARF output to something loadable on the emulated Jaguar, not something this repo's build wires up today.

  2. What DWARF, if any, does rln emit? Answered 2026-08-26, Phase 1 implementation: none. Verified by building the toolchain and actually linking a test object: rln -e output identifies as mc68k COFF object not stripped (file(1)) — a pre-ELF Atari-era object format, not ELF — so readelf -S does not apply at all, and objdump -h (Xcode's llvm-objdump) fails outright with "not recognized as a valid object file" against it: no toolchain on this host can parse it as DWARF-bearing. rln's own -g flag ("output source-level debugging") produced a file byte-for-byte identical (SHA-256) to the same link without -g, and neither contains any string referencing the source filename or a .debug_*-style section name. What Jaguar-era linking does give you is rln -m's load map: global symbol names resolved to segment-relative addresses (confirmed working — see the TEXT/DATA/BSS-SEGMENT RELOCATABLE SYMBOLS tables it printed for the smoke test). Conclusion: the honest claim is symbol-level debugging (breakpoints and reads by symbol name/address, resolved from rln -m's map or the object's own symbol table), never source-level or line-level. Every "source-level debugging" claim in this document has been corrected accordingly; do not reintroduce it without new evidence that some other tool in the chain (not rln) emits real DWARF.

    Further verified 2026-08-27, by reading rmac/rln source directly (mwenge/rmac@50e66ea, rln@a617009, both pinned in PIN) rather than only testing one linked output: exhaustive grep for dwarf and .debug_ (case-insensitive) across both tools' entire source trees returns zero matches, under any flag. rln's object-file magic-number detection only recognises BSD a.out (0107), DRI/Alcyon (601A), and ar archives — there is no ELF magic (0x7F454C46) check anywhere in it. rmac can emit an ELF object with a real .symtab/.strtab (object.c's ELFSectionNames / AddELFSymEntry), but that path is orphaned: rln structurally cannot read it, so it is never exercised by the actual working rmac→rln pipeline. And -g's real mechanism, per rln.c's OSTAdd(), is a filter that keeps or drops symbols whose type carries a 0xF0000000 "debug" tag in rln's own proprietary Atari-era symbol table — which is what explains the byte-identical link above: whatever symbol category that tag marks was never present to filter in the tested case, so the flag had nothing to do either way.

  3. Register numbering for the RISC target descriptions. Answered 2026-08-27, Phase 3 implementation: R0-R31 = index 0-31, PC = 32, FLAGS = 33 (34 registers total), identical for the GPU and DSP descriptions. Fixed now — do not renumber; see "GPU/DSP register files" below.

Phase 2/3 implementation notes (2026-08-27)

Phases 2-4 landed together. A few things resolved or turned out different from how this document originally described them:

  • The ALPINE_FUNCTIONS six-site claim ("Watchpoints") was verified true, but not for the reason it looked true at first. ALPINE_FUNCTIONS is not a build-system flag passed by any Makefile target — it is #defined unconditionally near the top of src/core/jaguar.c itself (line 80), so the six m68k_read/write_memory_{8,16,32} sites really are compiled into every build, exactly as this document said. (An earlier pass at this implementation grepped only the Makefiles for -DALPINE_FUNCTIONS, found nothing, and nearly reported this as a design- doc inaccuracy before re-checking the source directly and finding the #define in jaguar.c.) What was true is that the six sites were functionally dead: bpmActive was default-false and nothing in the tree ever set it, and the six sites' only consumer, M68KDebugHalt(), sets SPCFLAG_DEBUGGER, which makes m68k_execute() return early (unwind) rather than block — and nothing ever called the matching M68KDebugResume(), so the pre-existing "feature" would have wedged the 68K forever if anything had ever armed it. The six sites now call GDBMemWatchHit() instead of M68KDebugHalt(); this module is the first and only thing that ever sets bpmActive/bpmAddress1.
  • Single watchpoint slot, not a table. "Watchpoints ride the existing exported memory-access breakpoint vars" was implemented literally: one slot, matching bpmAddress1's singular shape. A second Z2/Z3/Z4 while one is already armed at a different address is refused (E01) rather than silently displacing it or growing a table. Documented as a known limitation in the user guide.
  • Stepping does NOT use the 68K's SPCFLAG_DEBUGGER path, contrary to what this document originally specified. All three processors' stepping (and monitor halt <target>, and the gdb_wait boot halt) share one one-shot primitive on the same per-target breakpoint-table structure GDBCheckPC() already uses: arm a flag that matches unconditionally on that processor's very next instruction hook, consumed immediately on match. Reasoning for the deviation: SPCFLAG_DEBUGGER makes m68k_execute() return early to its caller rather than block in place, so something outside the hook would have to notice "a step just completed" and re-invoke GDBHalt() — an extra layer of scheduler plumbing the one-shot primitive doesn't need, and which wasn't worth the risk of getting subtly wrong under time pressure when a uniform mechanism across all three processors was available and already necessary for the GPU/DSP anyway. SPCFLAG_DEBUGGER/M68KDebugHalt/ M68KDebugResume remain unused by this module.
  • GPU/DSP register/PC/flags accessors needed adding or un-gating. GPUGetReg/GPUGetPC predate this feature (issue #406) and were already unconditional; GPUGetFlags and DSPGetReg existed but were #ifdef VJ_TRACE-only and are now unconditional (matching GPUGetReg's existing precedent, not a new exception). DSPGetPC, and the write side of all of these (GPUSetReg/GPUSetPC/GPUSetFlags/DSPSetReg/ DSPSetPC/DSPSetFlags), plus read-only GPUGetControl/DSPGetControl for monitor regs, did not exist at all and were added. The writes are raw pokes (like m68k_set_reg), not simulated MMIO writes, specifically so that GDB setting PC or FLAGS does not also trigger a G_CTRL/D_CTRL- style side effect (e.g. toggling GPUGO) as a surprise side effect of a register-inspection action.
  • The RISC disassembler is genuinely shared, not just "not duplicated in spirit." src/debug/gdbdisasm.h is a header-only module included by both src/debug/gdbtarget.c (monitor disasm, live) and test/tools/gpu_disasm_dump.c (offline, dlsym-based) — the latter was refactored to drop its own inline copy of the mnemonic table and operand decode logic in favour of the shared one.
  • Multi-architecture GDB sessions (m68k thread 1 + custom-description threads 2/3) do NOT work with a real gdb -- verified against gdb 18.1 (issue #936). gdb reads one target description per connection and applies it to every thread; the first g for thread 2 fails ("reply is too long (expected 72 bytes, got 136 bytes)") and a GPU description is rejected without an <architecture>. Resolution: only the 68K is enumerated and described to gdb; the RISC cores are monitor targets (see "Targets" above). Same issue: the 68K now gets a real m68k description (without it target remote aborted), the watch gate in gdbtarget.c that duplicated ALPINE_FUNCTIONS (never defined there) was removed so Z2/Z3/Z4 work, D / vKill / k end a session without stopping the emulator, and long monitor output streams as O packets (the old 512-byte buffer could also be overrun by an unclamped snprintf accumulation in monitor regs). None of this touches a per-instruction or per-access path: the watch sites in jaguar.c and the gdbArmed* checks are unchanged.

Sequencing

Each phase is independently useful and independently abandonable.

  1. Protocol layer + 68K target, no breakpoints. Attach, read registers, read memory, detach. Proves the transport and framing. Shipped (issue #652, PR #662).
  2. 68K breakpoints, watchpoints and stepping, on the armed-flag gate. Run test layer 4 here — this is the go/no-go for the whole shipped-by-default premise. Shipped — see the PR for this phase for the perf measurement's result and the machine load it was (or wasn't) taken under; do not treat this document as the record of that number.
  3. GPU and DSP threads, XML descriptions, monitor disasm. Shipped, with the multi-architecture-session caveat above.
  4. Documentation: a user guide covering attaching, the frontend-freeze behaviour, and the RISC disassembly limitation. Shipped as docs/gdb-stub-guide.md.