3.9 Debug and the debugger¶
The debugger is the developer-facing half of jnext: a way to stop the machine,
look at everything inside it, change some of it, start it again — and, when
rewind is on, run it backwards. It is not a separate program talking to the
emulator over a wire. It lives in the same process and the same thread as the
emulation, so a panel reads machine state by calling straight into Emulator
rather than marshalling it across a boundary, and the run loop consults the
debugger's state once per instruction, before the fetch. That single
consultation point is what makes stepping exact and breakpoints cheap.
The dependency runs one way and stops halfway. The emulator core owns and consults the debug backend — it is an ordinary member, present in every build — while the Qt UI sits above both and can be compiled out entirely.
Two directories, and the split is the design¶
src/debug/ (target jnext_debug) is the backend: disassembler,
breakpoint and watchpoint sets, execution-control state, instruction trace log,
call-stack tracker, symbol table, rewind ring buffer. It has no Qt dependency
at all. src/debugger/ (target jnext_debugger) is the Qt 6 UI and
nothing else — panels, menus, the debugger window.
Three things fall out of that. The backend is testable without a GUI:
rewind_test and resume_guard_test link it with no Qt anywhere, while the
debugger_* suites are the ones that need a Qt build. The emulator can own
debug state permanently without dragging Qt into the core. And because the
backend is present in every build, --magic-breakpoint and --trace are
plain CLI flags rather than GUI-only features.
One caveat about "pure": jnext_debug does link SDL2, because
rewind_buffer.cpp includes core/emulator.h, which reaches input/keyboard.h
and thence SDL.h. The rule the split enforces is no GUI toolkit, not no
dependencies.
What ENABLE_DEBUGGER=OFF removes¶
ENABLE_DEBUGGER (default ON) gates only the Qt UI. With it off,
jnext_debugger is neither compiled nor linked and every use site in
src/gui/ sits inside an #ifdef. jnext_debug is linked unconditionally,
and Emulator::debug_state_ is an ordinary member either way.
That is deliberate, because the hot loop's cost is not "is the debugger
compiled in" but "is it active". DebugState::active_ starts false and turns
true only when the UI enables the debugger or a magic breakpoint fires.
There are two booleans, and the split is load-bearing (GH #219).
active_ means the debugger is driving the machine: it gates the step modes
(OUT, STEP_BACK, RUN_BACK_TO_CYCLE), the "render every frame" hint that
keeps the panels showing a live framebuffer, and video_timing_.advance(),
which maintains raster counters nothing but a human inspector ever reads.
armed_ — active_ || persistent_ — is the narrower are breakpoints live,
and it is what the per-instruction breakpoint test and the watchpoint checks in
Mmu::read/Mmu::write hang off. persistent_ comes from
--persistent-breakpoints via EmulatorConfig, and is what lets breakpoints
survive closing the debugger window without switching the rest of that
machinery back on. The call-stack pre/post hooks sit behind their own
enabled() flag. The watchpoint checks are triple-gated — pointer non-null,
armed(), and has_any_watchpoints().
armed_ is a cached bool recomputed by the two setters rather than an
expression, so the default configuration executes exactly the load-and-branch
the single active() gate used to.
So "the debugger costs nothing when closed" is a claim about a predictable branch, not about conditional compilation.
Execution control¶
DebugState holds paused_, a StepMode, and a BreakpointSet. The run loop
consults it once per instruction, before the fetch:
| Mode | Set by | How it terminates |
|---|---|---|
NONE + paused_ |
pause() |
run_frame() returns immediately |
| PC breakpoint | BreakpointSet::add_pc |
should_break(pc) matches |
INTO |
step_into() |
loop pauses on the next iteration |
OVER |
step_over(next_pc) |
one-shot breakpoint at next_pc |
OUT |
step_out(sp) |
check_step_out() matches, after the instruction |
RUN_TO_CYCLE |
run_to_cycle() |
master clock reaches the target |
STEP_BACK / RUN_BACK_TO_CYCLE |
step_back(), run_back_to_cycle() |
handled before the loop starts, by rewinding |
| watchpoint | add_watchpoint |
Mmu latches data_bp_hit; checked after the instruction |
Every one of those modes leaves paused_ through DebugState::unpause_(),
which also arms a one-instruction step-off. The loop consumes it
immediately before should_break(), and that first test of a resumed run is
the only one it suppresses. It has to exist because the breakpoint check sits
before the fetch: without it, resuming with PC still on a breakpoint
re-matched the unchanged address and the machine re-paused having executed
nothing — issue #221, and it pinned Step Over, Step Out, Run to Cursor and Run
to EOF in place exactly as it pinned F5.
The arm suppresses the resumed-from address without storing one, and the
property that makes that sound is a precondition, not an accident of the
call sites: unpause_() raises the arm only on a real paused → running edge.
Given that, the machine was stopped between the arm and its consumption, so the
PC the gate sees is necessarily the PC the resume was issued at. Without the
edge check the guarantee is simply false — a resume issued on an
already-running machine arms against a PC that keeps moving, and the arm lands
on a later, unrelated breakpoint and swallows it. The UI reaches that state by
ordinary use: the debugger toolbar's F5: Continue button is a plain
QPushButton with no enable gating, and MainWindow's global F5 handler
forwards to on_run() whenever the debugger is enabled, regardless of whether
the machine is paused. The arm is also dropped by refresh_armed_() when
breakpoints go dead, because the consumer stops running there while PC does
not.
Step Over is not a special CPU mode. DebuggerManager::on_step_over() asks the
disassembler whether the current instruction is_call_like() — CALL nn,
CALL cc,nn, RST n, DJNZ — and if it is, sets a one-shot breakpoint at
PC + instruction_length() and resumes; otherwise it degrades to Step Into.
Run to Cursor is the same one-shot mechanism with a user-chosen address, and
Run to End of Frame and End of Scanline are run_to_cycle() with a computed
target.
Step Out is the one mode whose termination is decided after the instruction
rather than before it. step_out() records SP at the moment F8 was pressed;
check_step_out() is then called from the shared per-instruction body once per
instruction and ends the step when three things hold together: the instruction
popped exactly one return address (sp_after == sp_before + 2, which is what
tells a taken RET cc from an untaken one), its opcode was a return form
(RET, RET cc, RETI/RETN including the undocumented ED aliases), and
the pop unwound the stack strictly past the armed SP. That last condition is
what makes a nested call's own RET — and an interrupt handler's RETI —
return into the routine being stepped out of rather than end the step.
It is called from step_one_instruction(), not from run_frame()'s loop, so
free-running and single-stepping cannot disagree about where a step out ends.
That call did not exist at all until issue #203 was fixed: the mode was written
and never read, and F8 had the observable behaviour of Run.
Its position inside that body is load-bearing in both directions, and the
reason is the general one for any debugger hook here — it may only read
memory the CPU itself read. Mmu::read() is not inert: it fires read
watchpoints and latches the +3 floating bus. Reading the opcode speculatively
before the instruction runs is therefore unsafe, because Z80Cpu::execute()
has three early returns that complete a step without ever fetching at PC —
an accepted NMI, an accepted INT, and the esxdos shim. In those slots the
debugger's read is the only touch of that address, and with a watchpoint on it
the phantom hit ends the step at the interrupt vector instead of the routine's
return.
So the CPU is asked rather than deduced: fetched_opcode_last_execute()
is false through all three early returns and true only once the fetch has
happened, and the read is gated on it. Deducing it from SP movement does not
work, and the reason is worth knowing before inventing a fourth shim: NMI and
INT push, so they do move SP the wrong way and an arithmetic test catches
them — but the esxdos shim deliberately fakes a return's own +2, and is
indistinguishable from a real RET by arithmetic alone.
The decision also sits before the deferred RETN overlay clear, because a
RETN leaving a DivMMC-mapped routine unmaps it there, and a later read would
see the underlying page rather than the ED 45 the CPU fetched.
Single-stepping goes through Emulator::execute_single_instruction(), which
shares its per-instruction body verbatim with the free-running loop
(step_one_instruction()), specifically so that the two paths cannot drift.
Stepping must observe the emulation and never alter it — see
2.2 The emulator core for why
run_frame() refuses to re-begin a frame that is already in progress.
That function is the raw one-slot primitive, though, and the debugger does
not use it directly: DebuggerManager::on_step_into() calls
Emulator::debugger_step(). The difference is the frame boundary, which is
easy to overlook — while the debugger holds the machine the frontends stop
calling run_frame() altogether, so a step is the machine's only driver and
inherits its frame loop as well as its inner one. step_frame_slot() begins a
frame when none is in flight and calls the shared end_of_frame() once the
clock reaches the frame's last cycle. Without it, everything scheduled per
frame — the ULA frame interrupt above all — stops being scheduled the moment
the debugger pauses, and a HALTed CPU can never be woken (GH #207). A step
issued at a HALT consequently runs the halt out rather than stepping one of
its internal NOP slots, bounded to two frames: the CPU leaves the halt only on
an accepted interrupt or NMI, and stepping a slot in which nothing observable
can change is not a step.
The primitive keeps its frame-agnostic behaviour deliberately. Much of the test tree uses it to advance a machine whose frames the test drives itself, and several suites depend on a step not touching frame state at all.
Two consequences of the halt-run are worth knowing. A Step that ends on a
watchpoint consumes data_bp_hit_, exactly as run_frame() does — the
halt-run loop reads that flag, so a latch left set would make every later Step
collapse back to a single NOP slot. And Step and Step Back stop being
inverses across a halt: one Step can execute ~10 000 internal NOP slots,
enough to saturate the circular trace buffer, while Step Back still undoes N
raw instructions. Nothing corrupts — the rewind buffer's own frame snapshots
are taken normally — but the two controls are counting different things, so
stepping back out of a halt is not one press.
Panels¶
Thirteen panels, created by DebuggerWindow::create_panels(). What each one
introspects:
| Panel | Reads |
|---|---|
| CPU Registers | the Z80 register file, flags, IFF/IM, halt state, active ULA screen |
| MMU | the 8 slot→page map with RAM/ROM type, plus the 128K bank view |
| Disassembly | src/debug/disasm.* over Mmu::read, with symbol substitution and a breakpoint gutter |
| Memory | raw bytes, either through the CPU's address space or a chosen MMU slot |
| Stack | words at and above SP |
| Call Stack | src/debug/call_stack.*, a shadow stack built from SP deltas |
| Watches | byte / word / long at user addresses |
| Breakpoints | the contents of BreakpointSet |
| Video | each layer rendered separately — composite, ULA primary and shadow, Layer 2 active and shadow, sprites, tilemap, and the NR 0x4A fallback colour |
| Sprites | all 128 sprite attribute slots |
| Copper | the decoded Copper program and its PC |
| NextREG | the whole 256-entry register file, editable |
| Audio | AY registers per chip, and the per-source mute mask |
For what these look like and how to drive them, see chapter 6 of the user guide, The debugger — a UI reference written against the running product, and not repeated here.
Four panels (CPU, Disassembly, Stack, Call Stack) update only while paused. That is a performance decision as much as a legibility one: reading the register file every frame while the machine runs produces a blur, at real cost.
Everything else refreshes on DebuggerManager's timer — with one exception.
The two views of the breakpoint set (the Breakpoints list and the
disassembly gutter) also observe it: BreakpointSet::add_observer() takes a
std::function<void(BreakpointChange)> — a plain callback, because src/debug/
is Qt-free — and every mutator calls it, so a breakpoint appears the instant it
is set rather than on the next tick. The two subscribers differ, deliberately:
the list acts on both change kinds, the gutter only on PcBreakpoints, since it
paints has_pc() and nothing else. One-shot breakpoints notify nobody — they
are transient, are set on every resume, and no panel draws them.
The point is where the notification comes from. A dozen call sites mutate that set, and each one used to be responsible for repainting the views itself; twice a site was added that did not, and the panels lied until the next tick. Emitting from the mutator means a new call site cannot get it wrong.
Symbols¶
src/debug/symbol_table.* is a bidirectional address↔name map with two
readers: load_z88dk_map() for z88dk linker output, and load_simple_map()
for a plain SYMBOL = $ADDR list. DebuggerManager owns the table, and the
disassembly, breakpoint and watch panels all consume it — which is why a
breakpoint set on a symbol keeps its name in the breakpoint list.
Rewind¶
Rewind lets you run the machine backwards: step back an instruction at a
time, jump back a whole frame, or drag a slider to somewhere earlier in the
session and carry on from there. For a Z80 developer that turns the usual
debugging move inside out — instead of guessing where to put a breakpoint,
re-running, and finding you have overshot again, you take the crash and walk
back from it to the instruction that caused it. It is off by default, because
it costs a full machine save every frame plus the memory the ring occupies;
--rewind-buffer-size N or the debugger's own toggle turns it on.
The implementation is not an undo log. It is a ring of whole-machine
snapshots taken at frame boundaries. RewindBuffer allocates one mmap
region of max_frames × snapshot_bytes up front, so pages fault in lazily
rather than being memset, and Emulator::run_frame() writes a slot at the top
of each frame, overwriting the oldest once the ring is full. Stepping back to a
point inside a frame means restoring that frame's snapshot and replaying
forward to the target instruction, which is why enabling rewind also
force-enables the trace log: step_back() needs the trace to know which cycle
the target instruction started at. The replay runs with audio and video
suppressed, so a long rewind neither screeches nor flickers.
The snapshot itself is produced by the same save_state / load_state
interface every subsystem implements, described in
2.5 Save state and rewind.
Two of its properties are load-bearing here:
-
The stream must be fixed-width. Slot size is measured once at construction, by a dry run in measure mode. Any field that serialises a runtime length silently widens the stream, and from that moment every snapshot is dropped. That is not hypothetical — it shipped once, and a single
OUT (0xFF),Awas enough to trigger it.rewind_buffer.hcarries the audited enumeration of every variable-length candidate and what was done about each. -
A failed restore is loud. A snapshot that does not round-trip is never published, a restore that fails verification returns a sentinel instead of reporting success, and the emulator latches
last_state_error(). The UI then refuses to resume a machine known to be corrupt without explicit confirmation, via theResumeGuardpolicy insrc/debug/resume_guard.h.
Magic breakpoint and magic port¶
These two are hooks a Z80 programmer puts in their own source, and neither
exists on real hardware. They solve the two problems that come up constantly
when the code under test is on the other side of the emulator: stopping at a
place you marked in your source rather than at an address you had to look up,
and getting a value out of a running program without opening a debugger at all
— a printf that lands on the host's terminal instead of on the screen the
program is busy drawing.
The magic breakpoint is an opcode that pauses the debugger where it
executes. jnext intercepts it in src/cpu/z80_cpu.cpp before the FUSE core
sees it, and recognises both community conventions: ED FF
(ZEsarUX/Spectaculator) and DD 01 (CSpect). With --magic-breakpoint set,
Emulator::init installs an on_magic_breakpoint callback that activates and
pauses DebugState; the opcode then advances PC by two and costs 8 T-states.
With the flag unset the callback is null and both sequences fall straight
through to normal Z80 decoding — which is the point, because it means the hook
can be left in shipped source instead of being conditionally assembled out.
The magic port is not a CPU feature at all. It is an ordinary port handler
registered on a full 16-bit decode (register_handler(0xFFFF, addr, …)) whose
write side prints to stderr in one of four modes: hex, dec, ascii or
line, the last buffering until CR/LF so a whole string arrives as one line.
Reads are not intercepted. Being a normal registration, it obeys exactly the
same dispatch rules as everything else in
3.6 Peripherals.