3.10.2 The Qt debugger¶
The Qt debugger is the debugger window with its panels, menus and toolbar,
in src/debugger/ (target jnext_debugger, built when ENABLE_DEBUGGER=ON
in a Qt build). It reaches the machine only through the backend:
DebuggerManager and DebuggerWindow are constructed on the Debugger alone,
and debug_qt_free_test fails the run if any file there includes a core-layer
header or names Emulator (3.9). For what the
window looks like and how to drive it, see chapter 6 of the user guide,
The debugger.
The adapter: two clients¶
DebuggerManager holds the loop owner's Debugger, handed over by
MainWindow::set_debugger(). It is two clients of it.
The window's client is attached exactly while the debugger window is open.
Opening the window attaches it, requests the live raster and turns call-stack
tracking on. Closing it resumes a paused machine — whoever paused it — and
detaches. Every verb the window issues is attributed to this client. It is not
attached for the life of the process, because an attach arms the machine, and a
window that armed breakpoints while closed would make --persistent-breakpoints
the default (debugger_persistent_bp_test PBPUI-03 pins the opposite).
The breakpoints' owner is a non-arming observer client, attached by
BreakpointModel (src/debugger/breakpoint_model.*) for the manager's
lifetime. The GUI's breakpoints are its subscriptions: single-address
Execute, Mem and Port subscriptions with no condition, once = false and
action Stop. READ, WRITE and READ_WRITE are one Mem subscription with an
access mask. An I/O address 00-FF is a low-byte match (port_mask 0x00FF);
0100 and up is exact. Because the observer never detaches and arms
nothing, a breakpoint survives closing the window, fires with the window shut
exactly when something else arms the machine, and costs a closed window
nothing.
The Breakpoints panel, the disassembly gutter and context menu, and the window's
Breakpoints menu all edit this one model. The panel lists every client's
subscriptions; another client's are read-only and named by client id. The
master switch is the backend's (set_master_enabled()).
The emulator window's Pause client is a third Qt client, owned by
MainWindow rather than the adapter, so it exists in a Qt build with no
debugger too. It is a non-arming observer, attached lazily on the first press of
Machine > Pause (Alt+U or the Pause key) and detached with the window. With the debugger window
shut, the action pauses and resumes through this client; with it open, the action
calls the adapter's on_pause() / on_run(), the same verbs as the forwarded F9
and F5 keys. In a build with no debugger it is also the only way, from the GUI, to resume a
pause made by a magic breakpoint or a script.
The pause state is pulled¶
A pause the window did not cause still opens it, with one exception: a pause
whose by is the emulator window's Pause client
(set_emulator_window_pause_client(); matched on the client id, so another
Gui or observer client still opens it) is exactly what that control exists to
make without the window. check_breakpoint_hit() reads
state() once per tick, after the pump, and brings the window to it in both
directions. A pause it has not shown gets the pause-edge sequence, opening the
window first if it is closed: a breakpoint, a magic breakpoint, a persistent
breakpoint, another client's pause. A resume it did not cause gets the running
sequence. What the window last showed is the one piece of state the adapter
keeps for this, and it is presentation state, not a copy of the machine's.
apply_pause_state(bool) is the one place both sequences live.
MainWindow::sync_pause_state() pulls the same state() once per tick, from
QtApp::TickEffects::post_frames(), for the Pause checkmark and the status
bar's Paused cell, with or without the debugger built in.
The listener records and returns: a listener runs inside pump() and does no UI
work. A breakpoint change another client made arrives as
SubscriptionsChanged; the model records it and the tick publishes it.
Panels¶
DebuggerWindow::create_panels() creates fifteen:
| Panel | Reads | Writes |
|---|---|---|
| CPU Registers | registers(), paging_ports(), ula_screen_regs() |
— |
| MMU | mmu_slots(), paging_ports() |
— |
| Disassembly | disasm_one() over memory_reader(), with symbols, columns Mem, Addr, BP column (the breakpoint gutter), [Label], mnemonic, opcode bytes, and a selection you can copy as assembly |
breakpoints, through the model |
| Memory | peek(MemSpace::cpu()), or a slot's or NR page's backing store |
poke |
| Stack | the words at and above SP, through peek |
— |
| Call Stack | call_stack(), a shadow stack: a CALL, RST, INT or NMI opens a frame; a RET that lands on the newest CALL's return address (or the newest INT or NMI frame's interrupted PC) ends that frame, any other taken RET ends every frame whose return slot is below SP, and an esxDOS call the shim services ends its RST frame; a JP (HL)/(IX)/(IY) onto the newest CALL's return point, or taken with SP above its slot when the routine has stored no copy of its return address above SP (a callee prologue's PUSH or EX (SP)), ends that frame (sccz80's EX (SP),HL / POP r; JP (r) helpers and l_case; a __z88dk_callee body's internal jumps keep it); a routine that drops its return address and leaves by a direct JP keeps its frame until a later RET ends it by SP |
— |
| Watches | byte, word or long at user addresses, through peek; a C variable row (read_c_value(), c_frames(), c_variables_status()), read only while paused |
— |
| Breakpoints | every client's subscriptions, through BreakpointModel |
the GUI's own |
| Video | raster() in all four counter domains plus the ULA fetch phase, and render_layer() for each of the eight views |
— |
| Sprites | sprites() |
— |
| Copper | copper() |
— |
| NextREG | nextreg_peek() for the whole register file |
nextreg_write() |
| Audio | ay_registers(), turbosound_enabled(), ay_mode(), stereo_mode() |
set_audio_mute_mask() |
| Script | the ScriptHost's loaded scripts, their rules and hits, the verdict line and the script log |
load, reload, unload, record |
| Source | source_location() (a pointer into the store) for the PC, and the source file it names |
the source steps and loads, through DebuggerManager |
| Variables | c_frames(), c_locals(), c_globals(), c_children(), c_variables_status(), call_stack(), mmu_slots(): the C variables of a call frame and the globals, as a tree, a value editable in place through write_c_value() (7.7) |
— |
Five panels (CPU, Disassembly, Stack, Call Stack, Variables) update only while paused:
reading the register file every frame while the machine runs produces a blur,
at real cost. Everything else refreshes on DebuggerManager's timer, except
the two views of the breakpoints.
Refresh cadence. Running, DebuggerManager::refresh_panels() (debugger_manager.cpp:980)
redraws the window on every REFRESH_INTERVAL-th tick (12, REFRESH_INTERVAL, debugger_manager.h:370: ~4 Hz). Paused, it
redraws only when a stamp changed since the last redraw (paused_stamp(), debugger_manager.cpp:969; PausedStamp,
debugger_manager.h:245), and calls update_actions() on every tick, since their inputs (an RZX started from
the main window, the rewind buffer freed) have no verb. A paused machine with nothing happening used to redraw
everything, the Video tab's composited frame included, fifty times a second. The stamp is:
- the whole CPU register file, compared field by field (a direct core write behind the backend, as the tests and host actions make);
- the master clock,
time().master_cycle(steps, run-to, rewinds); - the backend's
state_revision(), a counter thatImpl::bump_revision()advances (31 call sites insrc/debug/) for every MUTATE line of any client (log_mutate(),log_mutate_range()), a cold boot (on_cold_boot_done()), a landingrecheck()applied, and the metadata verbs:load_map()andclear_symbols(),load_source_map(),clear_source_map()andload_program_sidecars(), every subscription verb (subscribe,subscribe_source_line, the group, enable and master switches), the trace, call-stack, coverage and rewind settings, attach and detach, and the magic and persistent breakpoints. A map or SLD load needs no special call: it bumps the revision, so the tick redraws; - the host-input epoch (
input_epoch_):DebuggerManager::eventFilter()(debugger_manager.cpp:157), installed onqAppwhile the debugger is enabled, counts key press and release, shortcut, mouse-button press, release and double-click, and wheel events anywhere in the application. It is the generic catch-all for host-side changes made on user input (the CPU-speed menu writing NR 0x07). Mouse movement is not counted: hover and Kempston mouse capture must not redraw a paused window; - a flag,
source_file_changed: whether the loaded source file's modification time differs from the one loaded (SourcePanel::file_changed_on_disk(),source_panel.cpp:551), so an edit on disk still shows.
refresh_window() (debugger_manager.cpp:960) takes the stamp after the redraw, because a panel may itself touch the backend and
that must not read as a change at the next tick. A change of the Source tab's own view that moves nothing in the
machine (a colour scheme, follow_pc()) repaints the tab itself. take_panel_refresh_count() counts the
redraws; the ticks: line reports it as panel-refreshes=. The emulator window's half of the same issue,
the framebuffer generation and the upload gate that stops a paused machine re-presenting an unchanged frame,
is in 2.3.
The window's opening size. A window opening at its default size grows to the size where
nothing scrolls, if the screen has room (DebuggerWindow::grow_default_size_to_natural(),
debugger_window.cpp:509, queued from the first showEvent()); a size restored from the config file is left
alone. The size is measured: the scroll ranges are the amount of panel area out of view, and the left column
of tabs is widened by what its pages' sizeHint() lack, using the share of a widening the splitter really gave
it on the previous pass (fit_last_add_, fit_last_lacking_; bounded by kMaxInitialFitPasses = 4
(kMaxInitialFitPasses, debugger_window.h:204) and kMaxWidthFitPasses = 8 (kMaxWidthFitPasses, debugger_window.h:231)). The frame, not the client area, must fit the screen
(jnext::clamp_window_size_to_screen). A window manager can answer the fit's requests late and out of order,
so the fit is bounded in time as well: a resize to a size the fit asked for or started a pass from
(fit_sizes_, fit_known_size_()) is the manager's answer, not the user's, and the request is asked again,
at most kMaxFitEchoes = 6 times: the answer queues a pass, and the pass asks again and counts it, once
(resizeEvent(), debugger_window.cpp:496-507); any other size is the user's and ends the fit
(end_fit_(), debugger_window.cpp:480). The fit also ends by itself kFitGraceMs = 1000 ms after its last pass started (every
pass restarts that wait, kFitGraceMs, debugger_window.cpp:533-538), after which every size is the user's and the remembered sizes are
dropped; the grace does not end the fit while a pass is still queued (fit_passes_queued_, queue_fit_pass_()), so a stalled event loop cannot
cut the fit short between a pass and the one it queued. Rows: GR-06 (a stale answer inside the grace window is asked again), GR-07 (after it every size is the user's), GR-08 (an earlier request landing while a pass is queued), GR-09 (a user resize while the fit runs stands), GR-10 (each late answer counts once: the 7th stands) and GR-11 (a loop stalled past the grace window does not end the fit), GR-12 (the bound holds on the pass's own path), GR-13 and GR-14 (the grace waits for the pass a re-ask, or a late answer, queued), in debugger_window_grow_test; GR-09 to GR-14 use a window no window manager sees.
The breakpoint views follow the model. The Breakpoints list and the
disassembly gutter follow BreakpointModel::changed(kinds), so a breakpoint
appears the instant it is set. kinds is the set of event kinds whose listed
state changed, found by diffing the backend's subscriptions() listing against
the last one published: the list acts on any kind, the gutter only on
Execute. A transient subscription (Step Over, Run to Here) is not listed and
notifies nobody; a master-switch flip notifies every kind. Every model mutator
publishes before it returns, so a new call site cannot forget to repaint. The
backend's SubscriptionsChanged push carries the live kinds, not the changed
ones, which is why the model diffs rather than trusting it.
Writes are mutations. A NextREG edit is nextreg_write(), so the register's
own handler runs exactly as for NEXTREG nn,n. A Memory hex edit in the CPU
view is poke(MemSpace::cpu()), which writes through the live map. Both are
attributed to the window's client — to no client while the window is closed;
DebuggerManager::set_panels_client() keeps the three writing panels told —
and each produces a MUTATE line. Both are refused while an RZX records or
plays: the NextREG cell shows the register's value again at the next refresh,
and the Memory byte does not change.
A refused rewind says why in the status bar. DebuggerManager is also a
Listener of the window's client, installed at attach and removed before
detach, and only its on_log does anything. It records a line that starts with
REWIND REFUSED: (3.9.1), minus that prefix and
the backend's [client N] tag, and does no UI work in the push.
on_step_back() and on_rewind_to_frame() serve Step Back, Frame Back and the
slider. Each first clears the recorded line and any refusal still on show
(DebuggerWindow::clear_rewind_refusal()), because that refusal described the
previous verb. Each then calls the verb, and on a benign refusal hands the
recorded line to DebuggerWindow::show_rewind_refusal().
That puts it in the debugger window's status bar for kRewindRefusalMs
(10 s), with no modal. Meanwhile update_rewind_ui() leaves the status bar
alone, instead of writing its rewind status line there on every tick.
RefusedCorrupt is reported differently: a "Rewind Failed" modal and a line in
the main window's status bar. debugger_verbs_test's QRF-* rows pin each
reason's text, the hold and the clear.
The Memory panel's slot view reads the physical backing store. For a slot it
uses the space and space_offset that mmu_slots() reports — Page{nr_page}
for a RAM slot, Rom{...} for a ROM slot, whose poke is refused — at
space_offset + (addr & 0x1FFF). It never composes a space from
effective_page and is_rom. So an overlay over the slot (DivMMC, Multiface,
Layer 2) does not show in it, and its "Page..." item reads any NR page, mapped or
not.
The Video panel draws through the backend. Each layer view is
render_layer() (3.9.1). The panel keeps the
presentation: the QImage, the dark "not yet rendered" rows, the checkerboard
under every alpha-0 cell, the scaling, the red raster line and the titles.
Paused at a frame boundary (Debugger::at_frame_boundary(), raster row < 0) it
renders all 256 rows with no raster line: begin_new_frame() has not yet
re-baselined the per-scanline change logs, so they still hold the frame that just
ended. Paused in the top vblank of a frame in progress, it shows the placeholder.
Its raster block shows hc/vc, hc_ula/vc_ula, cvc and phc side by side,
each labelled with the VHDL signal it mirrors; cvc is labelled as what NR
0x1E/0x1F report. The derivation lives in src/debug/raster_state.* and takes
the live VideoTiming by reference, so there is no second table of raster
constants in src/debugger/.
Which memory an address is in. Every view names the memory behind an address through
debug/long_address.h, by way of src/debugger/memory_text.h; DebuggerWindow holds one
AddressStyle (Debug > Physical Addresses) and hands it to each panel and to the BreakpointModel.
Which space each view prints, and the columns that were added, are described in
7.7.
The key bindings are data¶
No shortcut in the debugger window is written at its call site. The inventory is
a table in src/debug/debug_keymap.cpp — each action with a config-file id, a
label and a compiled-in default — and DebuggerWindow::apply_keymap() is the
one place that pushes a jnext::dbgkeys::Keymap onto the QActions. It also
rewrites every toolbar caption and tooltip that quotes a key, so F5: Continue
is generated from the binding rather than typed next to it.
The model is Qt-free and lives in jnext_debug, not in src/debugger/,
because src/gui/ has to read and write the same table with
ENABLE_DEBUGGER=OFF: AppConfig must round-trip [debugger_keys] in a
debugger-less build, or the Preferences dialog would wipe a user's bindings. And
not in src/gui/, because ENABLE_QT_UI=OFF with ENABLE_DEBUGGER=ON is a
real build combination. The Qt conversions sit in the header-only
src/qt/debug_keymap_qt.h.
The vocabulary is bounded. Key is an enum of function keys, letters,
digits and named keys, not a mirror of Qt::Key. That lets parse_combo()
refuse a value by name, makes render_combo() canonical, and makes the grammar
testable without a widget. validate_combo() refuses the combinations that
would break something else: anything without Ctrl, Alt or Meta that is not
F1-F12 (the Memory panel types hex with bare keys), Alt+letter (the menu
bar's), Alt+1..8 (the script host keys), and Ctrl+C / Ctrl+A.
A chord both windows bind is a warning, not a conflict. Qt::WindowShortcut
matches against the active window, and each window keeps its own binding, so a
chord the emulator window binds (harvest_host_chords() reads them off the real
QActions) is accepted with a warning. Two actions in one window's map are a
conflict: Qt dispatches identical sequences round-robin and breaks both. The
Preferences tab refuses such a capture. build_keymap() resolves a hand-edited
file deterministically — explicit beats default, the earlier action beats the
later, the loser is left unbound — and every refusal becomes a LoadIssue that
MainWindow logs at error level and the tab lists.
MainWindow keeps the map in effect in debug_keys_. Both its forwarding of
the five execution keys (run, pause, step_into, step_over, step_out)
and the push into DebuggerWindow::set_keymap() read that member, so the two
windows cannot disagree. The five source-step actions are keymap actions too
(source_step_into and the rest), but they are debugger-window only: the
forwarding stays at the five instruction-level keys.
Symbols¶
The Map menu also imports and exports skoolkit control files and Ghidra symbol files: see 7.4.7.
The Map menu loads into the backend's one table with load_map(), and the Disassembly, Call Stack and
Breakpoints panels read symbols(). A breakpoint set on a symbol keeps its name in the list, and a symbol
any other client loads shows in the panels too. What each loader reads is
7.4; the Qt side is only the naming:
- The panels name an address in the memory that supplies it now (
Debugger::source_page()), throughdisasm_text::symbol_namer()for the disassembly; a Breakpoints row uses its own page when it has one, a Call Stack frame the page its target was entered in, and a breakpoint dialog keeps the page of a name assembled for one (resolve_key()). The address fields of the breakpoint and watch dialogs go throughSymbolTable::resolve(). - A z88dk C symbol is shown by its C name (
lookup(), design 10.6). The Disassembly copy askssymbol_namer(..., true)for the assembler name so the paste still assembles, the watch dialog labels an auto-filled watch withSymbolTable::shown_name(), and the Source tab's word menu offers the shown name too (rowsQCN-01to-05,DCN-01/02,QMAP-08). - The three Map > Load MAP File items (Z88DK, Simple, sjasmplus) are three handlers in
DebuggerManager, each callingload_map()and thenrefresh_panels(). The Breakpoints Symbol column and the Call Stack's target names are computed in the panels' ownrefresh(), so a load that skipped it left the previous names on a paused machine (rowsQMAP-05andQMAP-06). A file refused rather than unreadable has the backend's reason appended to the failure box (on_load_map_z88dk(),on_load_map_simple(), rowsQMAP-07).
Labels and comments of your own¶
The Disassembly and the Source tab share one menu builder, add_annotation_actions()
(src/debugger/annotation_menu.cpp:77), for Add / Rename Label…, Remove Label and Add / Edit Comment…;
DisasmPanel draws a Label column and the first line of a comment, and Map ▸ Open / Save / Save As
Annotations and the quit question about unsaved changes are DebuggerManager's. The mechanism and its rows
are 7.4.6, section 10.
Source¶
The Source tab (source_panel.*) shows the PC's line from the backend's source map and carries the
source-step buttons; it holds no state of its own beyond the loaded file, its modification time, where the
view was last put, and the sidecar reason report_sidecars() gave it. Its refresh, the file search path, the
highlighter and colour schemes, the gutter and breakpoint rows, the context menu, the selection sync with the
Disassembly and the Debug > Source actions are described in
7.7, with the mechanism behind each. The menu tree and the
bindings are pinned by debugger_accel_test and debugger_keymap_test.
Memory coverage¶
Debug ▸ Coverage (Enable Coverage, Clear Coverage) drives INS-21
(mem_coverage_enable/clear, 3.9.1); the check mirrors
the backend on every refresh_panels() and at construction, so a window built over
a backend that --coverage already switched on shows it ticked. All colours come
from coverage_colors.h (X green, R blue, W orange-red, the Source tint a pale
green), each through palette_tint; the Source tab alone takes its tint from the scheme
(SchemeColours::coverage_line: Light is coverage_colors::exec_line() of a light palette, Dark and
Borland their own dark greens, so every class colour reads on it, QSRC-195 clause 5, QSRC-200). The Disassembly paints a 3 px bar right of the
gutter separator on a line where mem_coverage_fetch(addr) has X, after the PC and
selection fills; the Memory panel asks mem_coverage() for each row through the
same locate() as the bytes and underlines each covered byte in thirds, X | R | W,
with a legend label in its top bar; the Source tab adds a full-width
ExtraSelection, in the scheme's tint, for each line in executed_source_lines(file) before the
current-line selection, so that one wins on its own line. Nothing is drawn while
coverage is off.
Copying out of a custom-painted panel¶
The Disassembly panel paints itself, so there is no Qt text widget to inherit a
selection from. The selection is an address range — an anchor and a cursor —
not a pair of line indices, because the painted line list is rebuilt by every
scroll and refresh while an address survives all of them. The copied text comes
from src/debug/disasm_text.*, Qt-free, which re-disassembles the selected
range from live memory rather than reusing the painted lines, so a selection
copies in full after its lines have scrolled away. The same file holds
apply_symbols(), which the painter calls too, so the copied and the painted
text cannot drift. debugger_disasm_copy_test drives the real panel with real
events and reads the real clipboard.
Tests¶
The Qt suites are gated on a Qt + debugger build and run in make unit-test:
debugger_verbs_test (the verbs, the pause edge and the rewind, trace and MAP
controls), debugger_panels_test (each panel's displayed text), debugger_video_panel_test, debugger_audio_panel_test,
debugger_inspect_watchpoint_test, debugger_persistent_bp_test,
debugger_keymap_test, debugger_menu_test, debugger_disasm_copy_test,
debugger_quit_gate_test, debugger_variables_panel_test (the Variables tab, QVAR-*, editing QVARW-*), and the window-geometry suites. The regression rows
qt-host-probe-func and qt-host-order-func exercise QtApp's hosting.