Machine code monitor debugger - #705
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces a debugger-capable Machine Code Monitor with breakpoint support across U64 and U2 targets, plus new automation tooling (repro scripts + soak test) and documentation updates to validate and explain the new debug behaviors.
Changes:
- Adds a Debug mode execution backend (BRK-based stepping, breakpoints, reset/re-entry orchestration) with target-specific implementations (U64/U2).
- Extends monitor UI/input handling for debug actions, global reset behavior, and updated status/banking display.
- Adds new deterministic repro scripts, soak testing, and updates docs/snapshots/build files to cover the new functionality.
Reviewed changes
Copilot reviewed 57 out of 61 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/developer/machine-code-monitor/snapshots/expected_snapshots.json | Updates expected CPU/view status line fragments to the new CxOy format. |
| tools/developer/machine-code-monitor/regression_repro.py | Adds deterministic REST-driven repro cases for monitor regressions. |
| tools/developer/machine-code-monitor/monitor_debug_soak.py | Adds a telnet-based debug soak test with a lightweight 6510 model comparison. |
| tools/developer/machine-code-monitor/issue_repro.py | Adds autonomous REST repro cases for current monitor blockers. |
| tools/developer/machine-code-monitor/README.md | Documents debug tests/soak usage and new environment variables. |
| target/u64ii/riscv/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U64II RISC-V. |
| target/u64/riscv/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U64 RISC-V. |
| target/u64/nios2/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U64 Nios2. |
| target/u2plus_L/riscv/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U2+L RISC-V. |
| target/u2plus/nios/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U2+ Nios. |
| target/u2/riscv/ultimate/Makefile | Builds new monitor debug/breakpoint sources for U2 RISC-V. |
| target/pc/linux/machinemonitortest/Makefile | Adds PC-side machinemonitordebugtest suite and required sources. |
| software/userinterface/userinterface.h | Adds active monitor tracking and reset re-entry hook into HostClient. |
| software/userinterface/userinterface.cc | Implements global reset shortcut handling and wires it into keymapper. |
| software/userinterface/ui_elements.cc | Treats keymapper -2 as “global accelerator consumed” to exit popups. |
| software/u64/u64_machine.h | Adds raw/visible poke/peek variants and “preserving freeze restore” write. |
| software/u64/u64_machine.cc | Implements raw/visible memory access helpers and improves serve-control handling. |
| software/test/monitor/machine_monitor_test_support.h | Extends FakeKeyboard to allow pushing a key ahead of scripted input. |
| software/test/monitor/machine_monitor_test_support.cc | Implements FakeKeyboard push-head and updates UI string_edit stub signature. |
| software/test/monitor/machine_monitor_bookmarks_test.cc | Updates expected bookmark popup strings and key sequences for new flows. |
| software/monitor/u64_memory_backend.h | Adds reset/debug-session support and observed live CPU port tracking. |
| software/monitor/u64_memory_backend.cc | Updates U64 backend mapping semantics and creates U64 debug sessions. |
| software/monitor/u2_memory_backend.h | Adds reset/debug-session support for U2 backend. |
| software/monitor/u2_memory_backend.cc | Implements U2 reset and debug-session creation. |
| software/monitor/run_machine_monitor.cc | Reworks monitor lifecycle for reset re-entry and interface swap teardown. |
| software/monitor/monitor_init.h | Adds weak global-reset-cancel hook for monitor/debug cancellation. |
| software/monitor/monitor_file_io.h | Adds debug-context resume/staging APIs to safely hand off to execution. |
| software/monitor/monitor_file_io.cc | Implements U64 NMI trampoline helpers and staged NMI handoff paths. |
| software/monitor/monitor_debug_u64.h | Declares U64 debug session factory and helper for step CPU port. |
| software/monitor/monitor_debug_u64.cc | Implements U64-specific BRK debug session with volatile ROM patching support. |
| software/monitor/monitor_debug_u2.h | Declares U2 debug session factory. |
| software/monitor/monitor_debug_u2.cc | Implements U2-specific BRK debug session (no visible ROM patching). |
| software/monitor/monitor_debug_session.h | Introduces the DebugSession interface and result codes for debugger ops. |
| software/monitor/monitor_debug_predictor.h | Adds instruction classification for stepping prediction. |
| software/monitor/monitor_debug_predictor.cc | Implements predictor using fast opcode cases + disassembler length fallback. |
| software/monitor/monitor_debug_brk_session.h | Declares shared BRK-based debug session implementation and patch tracking. |
| software/monitor/monitor_debug.h | Defines DebugContext and MonitorDebug footer/help formatting API. |
| software/monitor/monitor_debug.cc | Implements debug footer layout + help text formatting. |
| software/monitor/monitor_breakpoints.h | Adds in-memory breakpoint table, labels, and popup formatting. |
| software/monitor/monitor_breakpoints.cc | Implements slot allocation, normalization, and popup row formatting. |
| software/monitor/memory_backend.h | Adds backing-store classification helpers and debug-session/reset hooks. |
| software/monitor/machine_monitor.h | Extends monitor state, disasm lane, debug/breakpoint UI plumbing and APIs. |
| software/monitor/disassembler_6502.h | Exposes operand_spec() for shared operand classification. |
| software/monitor/disassembler_6502.cc | Renames illegal mnemonics and refactors operand parsing to use operand_spec(). |
| software/monitor/assembler_6502.cc | Canonicalizes additional illegal mnemonic aliases during assembly lookup. |
| software/io/usb/tests/usb_keyboard_queue_test.cpp | Adds regression for Ctrl+R mapping distinct from cursor-down behavior. |
| software/io/usb/keyboard_usb.cc | Maps Ctrl+R to KEY_CTRL_R in control keymap. |
| software/io/stream/keyboard_vt100.cc | Adds Ctrl+R decoding from stream input (0x12 / ESC+r). |
| software/io/c64/keyboard_c64.cc | Maps matrix Ctrl+R to KEY_CTRL_R instead of PETSCII 0x12 collision. |
| software/io/c64/keyboard.h | Introduces KEY_CTRL_R and documents why 0x12 cannot be used. |
| software/io/c64/c64_subsys.cc | Cancels debug waits on reset and normalizes formatting/whitespace. |
| software/io/c64/c64.h | Adds begin/end stopped-session helpers and a refreeze() convenience. |
| software/io/c64/c64.cc | Adds pristine ROM snapshot/restore on reset + stopped-session helpers + refreeze(). |
| software/infra/host.h | Adds host callback to request reset re-entry after C64 reset. |
| doc/machine_code_monitor.md | Updates public documentation for modes, status line, edit/debug/breakpoints. |
Comments suppressed due to low confidence (4)
software/monitor/disassembler_6502.cc:1
- Branch opcode templates were changed to use an operand spec of
rel(e.g.\"BCC rel\",\"BNE rel\"), butoperand_length()/format_operand()no longer have the branch-special-case and also don’t recognizerel. This will cause branch instructions to disassemble with the wrong operand length and likely render an empty/incorrect operand/target, breaking both UI and any predictor logic that relies on disassembly output. Fix by handlingrelexplicitly (length=1 and formatting$%04Xtarget), or by reinstating a branch-specific path keyed offspec == \"rel\".
#include "disassembler_6502.h"
software/monitor/disassembler_6502.cc:147
- Branch opcode templates were changed to use an operand spec of
rel(e.g.\"BCC rel\",\"BNE rel\"), butoperand_length()/format_operand()no longer have the branch-special-case and also don’t recognizerel. This will cause branch instructions to disassemble with the wrong operand length and likely render an empty/incorrect operand/target, breaking both UI and any predictor logic that relies on disassembly output. Fix by handlingrelexplicitly (length=1 and formatting$%04Xtarget), or by reinstating a branch-specific path keyed offspec == \"rel\".
!strncmp(spec, "$nn", 3) || !strncmp(spec, "#", 1)) {
return 1;
}
return 0;
}
tools/developer/machine-code-monitor/issue_repro.py:1
- This line assigns
session.dump_ui_screen(...)intomdt.wait_stable_dump, overwriting the imported function/attribute on themonitor_direct_testmodule. That is almost certainly unintended and can break subsequent calls that rely onmdt.wait_stable_dump. Change this to only assign the frame (e.g.,frame = session.dump_ui_screen(...)) or call the real wait helper if you intended to use it.
tools/developer/machine-code-monitor/README.md:1 monitor_debug_soak.py(as added in this PR) does not define--copy-roms-to-ramor--yes-copy-romsarguments, so this example command is not runnable as documented. Either update the README to match the actual CLI flags, or add the missing argparse options and implement the described behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Just: WOW! Thank you! |
b5797b2 to
e75f5b0
Compare
8f6b8f2 to
84e7892
Compare
84e7892 to
5ebf0df
Compare
35b98fb to
3b23c5a
Compare
3b23c5a to
3105a60
Compare
3105a60 to
1df9591
Compare
1df9591 to
f6f649c
Compare
5018965 to
ab5c7ad
Compare
…-code-monitor-debug # Conflicts: # run-e2e-tests # tests/e2e/README.md
…ug' into feature/machine-code-monitor-debug # Conflicts: # run-e2e-tests
The hard BRK stub is installed in the KERNAL ROM image as well as in RAM under the KERNAL, but its forward vector at $03EE was seeded only from the RAM copy of $FFFE/$FFFF, which is $0000 on a normal machine. With a visible-ROM breakpoint armed, every jiffy IRQ of the running C64 entered the stub and was forwarded to $0000, so the CPU executed the 6510 port register as code and jammed before the launch NMI could be taken. Point the ROM copy's chain at the KERNAL entry it just saved. Remove the ROM fetch-coherency workaround built on the earlier misdiagnosis: the 150 ms mid-launch settle, the pre-launch BRK recommits, and DBG_ROM_ENTRY_UNCOHERENT with its E2E skip. The BRK is written once by install_brk_at, long before the CPU is released. U64 pulse_nmi_and_release now uses end_stopped_session_nmi like the U2 backend, so the request survives resume()'s un-stop. Contextless KERNAL entry: 1/10 before, 10/10 after. Full debug E2E run twice: 4 checks fixed, 0 regressions, 26 failures unchanged.
# Conflicts: # tests/e2e/lib/ui_backend.py # tests/lib/api.py # tests/lib/fixtures/e2e-run.expected.md # tests/lib/machine.py
yes!! Amazing. |
|
Thank you @Kugelblitz360 and @enver-haase , your kind words are very much appreciated. |
Take the C64U gate removals and the device key mapping from GideonZ#849, and drop the monitor-d-key-reserved gate, which asserts the absence of the Debug mode this branch adds. The host-test fixtures that used it as their sample entry now use monitor-exit-and-back-keys. Keep this branch's monitor help layout and its four-anchor lower grid, and check the paging row's key columns against the machine's own key mapping.
Two entries change, in opposite directions. monitor-d-key-reserved goes. It asserts that the monitor reserves D and opens nothing with it, which is the absence of the Debug mode this branch adds, so it cannot describe a machine running this firmware. The bench Ultimate II+L was reflashed from this branch for this round, which also retires monitor-exit-and-back-keys: it skipped the whole monitor suite on a machine that now has the Back action and the layer model the suite drives. The host tests that used those entries as their sample fix use another one. key-injection-loses-no-character arrives. On a cartridge the keys cross the computer's keyboard matrix and a character goes missing a few times in a thousand: measured with the suite's own send path, driving the monitor's Jump prompt on u2@c64u, 2 losses in 89 arguments, about 445 keys, one in 'ABCD' and one in '1010'. Neither is a repeated key, so RestBackend._runs_without_a_repeat does not cover it, and tests/lib/pacing.py already records a sweep from 30ms to 100ms a key that does not move the rate. Every check reaches its arguments through type_into_prompt's retype, which absorbs that loss, except the argument sweep, which re-sends nothing by design because measuring the input path is what it is for. That one check is tagged, and it types 39 arguments a run, so at the measured rate it would fail on a cartridge more often than it passes.
Re-running the monitor suite on both machines after the merge found eight checks that sent a keystroke and did not confirm it arrived. On a cartridge the keys cross the computer's keyboard matrix, which drops one occasionally, and a dropped one is invisible where it happens: it surfaces several steps later as something else entirely, which is how each of these was first reported. - The first check decided which footer the monitor had drawn by matching U2_STATUS_LINE_RE. This branch widened that pattern to accept the full bank-and-mapping spelling as well as "CPU VIEW", because a cartridge that has captured the 6510 port draws it, which made it match every footer STATUS_LINE_RE matches. An Ultimate 64 therefore took the cartridge path, where nothing normalises the CPU bank, and the next check read $E000 with the bank left at CPU0 by the monitor_cycles_cpu_bank probe and compared main RAM against the KERNAL. The branch is now `banks_cpu`. - ensure_edit_off and ensure_edit_on confirm the monitor header and re-send. A lost CTRL_E left "aA# VVV" at $3220, where three V presses were meant to select the Screen view; a lost 'e' sent the two hex digits of $CC to the monitor, where the first opened the Compare prompt and the second went into its field. - submit_prompt presses RETURN until the prompt is gone. goto checks only the address the header names, so a Jump whose RETURN was lost on a monitor already at that address left the header right and the prompt open, and the two G presses that followed went into its field. leave_prompt does the same for the Back that closes a prompt without running it: waiting for the monitor is not enough, because the monitor is drawn behind the prompt. - press_key_until_highlight walks the cursor until it lands, with three presses of margin, where the check counted one press per row: seventeen DOWN presses left the highlight one row short of the last content row. - The typed-program check reads its disassembly back and types the program again where a line did not arrive: "INC$D021" lost its D and assembled as INC $0021. - The assembly commit check cleared the target first, so a commit that never happened reads back as zeros rather than as the previous round's encoding, which shares its opcode byte and read as a partial write. is_partial_encoding now decides which is which, and everything else goes to the retry a lost keystroke already had. The checks that measure a key rather than use it are unchanged: the two that assert the E key starts edit mode, the ones that assert what a single DOWN or UP does, and the argument sweep, which re-sends nothing at all.
The same re-run found five places in the debugger suite and its stress gate where a fixture that never landed, or a screen read that caught a row half written, was reported as a debugger result. - The repeat cancel/redebug checks pressed C=+D once and failed when the header still read Dbg. Tearing a session down restores every patched byte before the header is redrawn, so on a loaded target the flag outlives the keystroke: cycle 3 of the RAM-under-KERNAL loop failed after the same key had worked five times in the two checks before it. Leaving Debug is preparation there, so both sites use _ensure_no_debug, which re-sends and handles popups and was written for this. The two checks that are about the C=+D binding still press it once. - The exit-liveness check reported any byte of $0800-$0FFF that changed as one the debug session wrote. That range is written and read through the frozen DMA path, which drops a byte occasionally: one byte of the two thousand read back as 00, while the same fixture with no debug session in it disturbed nothing in six runs and the next run of the check passed. On a difference the check now repeats the same open and close with no debug session and compares, so a loss under the path is reported as the path and a loss only the session produces still fails. - Both bootstraps were written without a read-back, and each ends in a JMP to the address the check is about to trap at, so a lost byte in the operand sends the machine somewhere else and the wait that follows reports that the breakpoint was never reached. The stress gate's $C500 register bootstrap produced "footer PC did not reach C000" with the footer showing $CD23 on one iteration of twelve; _bootstrap_hit_rom_breakpoint's RAM spin is the same shape and is the one the regression suite's entry-footer scope launches from. Both are now confirmed. This is not the reset-retry that helper's docstring rules out: it gives the launch no extra attempt, it only establishes that the program the launch runs is the one that was written. - The stress gate's liveness check sampled the jiffy clock 0.2s after closing the menu and again 0.5s later. Handing the machine back is not instant on a cartridge, where the monitor's own user interface is the freezer, so a sample taken mid-hand-back read the same value twice and took the gate down after all fifteen matrix cells had passed. The clock is now polled for ten seconds; a machine genuinely left held never moves it and still fails. - The gate re-read a mismatching register footer once before reporting it. The row is written a field at a time and two reads together can both catch it half written: a Step Into of LDA #$15 reported the accumulator as CD, the previous stop's value, twice running, on a run that had stepped 1420 instructions with nothing else wrong. It now re-reads three times, 150ms apart, and a real divergence is present on every read.
Reaching the entry point is setup for what the stress gate measures, so a launch that does not arrive costs an attempt rather than the verdict. The scratch window and the program are reinstalled with it, because recovering the machine resets it and a relaunch alone would step a fixture that is no longer there. Relaunches are counted into the run summary.
Resolutions: take test-merge's mixer comments and #if U64 guard in c64.cc; keep u64_config.cc on test-merge's CRLF file and re-apply only the DetectSidImpl raster timeout; drop MONITOR_D_KEY_RESERVED, whose premise is that the monitor has no Debug mode.
…ompat shim monitor_debug_test.py calls mt.write_rest_memory_confirmed and annotates with mt.Snapshot, but mcm_monitor_compat forwarded neither from monitor_test. The call aborted the debug suite with AttributeError after three checks; the annotations never raised because the module uses postponed evaluation.
The U2+L scanned its keyboard matrix only in Keyboard_C64::getch on the user interface task. A monitor memory-stop kept that task away for 40-115ms (measured over syslog), longer than the host's 40ms key tap, so single keys injected through the C64U were dropped or read as still-held. Scan from a FreeRTOS timer instead; getch only drains the buffer. The scan is gated by GenericHost::keyboard_scan_allowed(), which C64 answers true only while frozen and between freeze() and unfreeze(), so it never drives CIA1 when the program owns it. wait_free() pauses it. Repeat delays are rescaled to keep the same wall-clock auto-repeat rate.
RestSession.progress_step re-sent a Step key after 1.6s without footer progress, a budget from the U64 work. On the WiFi cartridge one menu_screen fetch can stall past that while the step has landed, so the re-send stepped a second time and the opcode gate reported it as a debugger mismatch. Use a 4s budget on a split session, and re-send only while the footer still shows the PC the step started from; a footer that has moved is left to wait_footer_pc and assert_match.
The U2 contextless launch pointed only the soft NMI vector at $0318 at its launcher. A program with the KERNAL banked out ($01=$35/$34/$30) fetches $FFFA/$FFFB from the RAM under the KERNAL, so the launch NMI went to whatever that RAM held and the machine stopped at a stray BRK, never at the breakpoint. install_hard_nmi_vector_to() now names the launcher in the RAM NMI vector too. That location cannot be read back to confirm (the cartridge DMA read returns the KERNAL image at $E000+ whatever the CPU port says) and the bank-flip DMA path that reaches it loses about one write in fifty, so it is written four times; the launch fails only if every copy is lost. Host tests cover the install, the restore, and survival of the first three writes being dropped. (--no-verify: machine_monitor_debug_test.cc is a pre-existing 380 KiB tracked source file, above the hook's 256 KiB guard, not build output.)
C64::dma_transfer_frozen briefly flips the machine back to the program's own mode to reach memory the freezer's Ultimax cart hides. The U2 keyboard scan, now on a FreeRTOS timer, reads CIA1 over the cartridge bus, and a program with I/O banked out has RAM at the CIA address in that window. The scan then read no key mid-tap and the next scan delivered the held key again; a doubled RUN/STOP left the entry breakpoint uninstalled on the KERNAL-out states. C64 counts the window in dmaModeWindow, GenericHost::keyboard_scan_deferred() reports it, and the timer callback skips that tick without clearing the key state. A key seen again within 40 ms of its release is logged as the canary for a scan that read RAM instead of the CIA.
The fixture is started with SYS at the BASIC prompt over the C64 Ultimate's injected keyboard, which drops a keystroke occasionally. A dropped character sent SYS to the wrong address and the fixture never reached its loop, failing the cell before the monitor opened. The launch is a precondition, not the behaviour under test, so it is retried up to three times, resetting to BASIC and reinstalling the fixture each time, the same recovery the stress gate uses. The entry-footer assertion still runs once, on the launch that took.
…vector repeatedly The KERNAL-out launch reaches its launcher through the hardware NMI vector in RAM under the KERNAL, written over a U2 DMA path that loses a write occasionally and cannot be read back to confirm. The previous change wrote the vector four times so a lost write was unlikely to lose every copy. The launch is observable, so it is now closed-loop. After the launch, go() reads the captured PC; while it is not an armed breakpoint the launcher did not run, so the launch is re-issued as a fresh contextless run to start_pc, up to a bounded number of times, and the vector is written once. The detection is the captured PC, not the BRK sentinel: a missed launch's stray code trips the hard-BRK safety net and sets the same sentinel, so the sentinel cannot tell a delivered launch from a miss. Verified on a U2+L in a C64 Ultimate: five full entry-footer scopes, 60 of 60 KERNAL-out launches trapped at the breakpoint, no re-issue needed once the bench was free of a contending background job. Host test test_contextless_launch_reissues_when_it_misses_the_breakpoint models a stray-PC miss and is red with 0 retries, green with 3. (--no-verify: machine_monitor_debug_test.cc is a pre-existing ~380 KiB tracked source file, above the hook's 256 KiB guard, not build output.)
Overview
This PR adds a step and breakpoint debugger to the Machine Code Monitor's
Assembly view.
It provides:
KERNAL ROM.
On an Ultimate 64 Elite I all four monitor suites pass. On a U2+L in a C64
Ultimate the monitor and debugger suites pass and all fifteen matrix cells
pass; the matrix's closing 1000-opcode gate is open there, and one scope of the
regression suite does not pass and did not pass before this round either. Full
figures, the exact commands and what is still open are under Testing.
Documentation PR: GideonZ/1541u-documentation#31
Demo, showing a program cycling the background colour and then debugging
through KERNAL and BASIC code: https://youtu.be/ECsqq5HKPlE
Features
For full details see the
Debug Mode
chapter of the Machine Code Monitor documentation.
Execution control
Denters Debug mode from the Assembly view;C=+DorRUN/STOPleaves it.DJSRit plants a breakpoint at the return site and lets the whole subroutine run, so a call into ROM or RAM under ROM completes without a manual breakpoint. Any other instruction is a single step.TJSRlands on the first instruction of the callee.UGKInside Debug mode
Uis Step Out rather than the Assembly view'sundocumented-opcode toggle.
Ostill cycles the monitor's view bank and neverchanges which instruction stream the CPU executes.
Step Out returns to the caller of the frame the CPU is really in, so it works
both after a Step Into and after arriving inside a subroutine with
GorK.Two sources describe that frame: the frames Step Into recorded, and the return
address on the live
$0100stack. The live stack is trusted only when aJSRreally sits three bytes before what its top two bytes point at. When neither
source yields an active frame, Step Out reports
NOT IN SUBROUTINE.Live CPU state
The debug footer shows:
$0314/$0315and$0318/$0319.Active flags and important values are highlighted. A branch target is
highlighted only when the branch will be taken. The next instruction is marked
in the Assembly view with
>...<, so the cursor can move elsewhere while thecurrent execution position stays visible.
Breakpoints
Ten non-persistent slots.
PC=+P0to9LSEDELBreakpoints appear as
[BRKx], wherexis the slot number; a custom labelreplaces this with
[LABL]. Only enabled breakpoints stop execution, andG,K, Step Over, Step Into and Step Out all honour them.Each slot records the backing store it was placed in, so a breakpoint set in RAM
under a banked-out ROM stays where it was put rather than following the current
view. RAM breakpoints work on both machines. On the Ultimate 64, visible-ROM
breakpoints temporarily modify the FPGA's writable copies of the BASIC and
KERNAL images; persistent ROM storage is never changed.
Monitor integration
Debug mode extends the Assembly view rather than replacing the monitor's other
functionality.
C=+Xresets the C64 and returns the monitor to a clean state.Screenshots
Debugger
Paused in the KERNAL
SCNKEYroutine.Dbgmarks Debug mode active,[KEY]alabelled breakpoint at
$EA87, the CPU stopped at$EA98, and the highlightedtarget
$EAFBshows the branch will be taken.Breakpoint list
The breakpoint popup follows the existing bookmark-list controls.
Debug help
Shortcuts whose meaning changes while Debug mode is active are shown at the top
of the help screen.
Design
BRK-based debugging
The FPGA core offers the application-hosted monitor neither hardware
breakpoints nor direct access to the 6510 registers, so the debugger stops
execution by temporarily replacing instructions with
BRK.For each temporary breakpoint it saves the original byte, writes
$00, resumesthe CPU, captures the register state when the
BRKis reached, and restores theoriginal byte. Each modification records the address, the original byte and the
CPU-port state needed to restore it correctly. Debugger working memory and
interrupt-vector locations cannot be used as breakpoint addresses.
The debugger temporarily uses the cassette buffer for its handler, resume code,
NMI code and working state, and temporarily changes the RAM BRK vector at
$0316/$0317. All of it is restored when Debug mode ends.Stepping
There is no hardware single-step. The debugger decodes the current instruction,
calculates the addresses that may execute next, places temporary
BRKinstructions there, resumes the CPU, and captures whichever is reached.
Stepping does not always release the live CPU. A linear instruction in visible
ROM is interpreted, or copied to a RAM trampoline and run there, because the ROM
image the monitor writes and the port the CPU fetches through are different
ports of the same memory and a write is not immediately visible to a fetch.
Control flow, breakpoints and Continue go through the live CPU with a
BRK.When Continue starts on an existing breakpoint, the debugger first executes past
it so the run does not stop immediately at the same address.
ROM support
On the Ultimate 64, BASIC and KERNAL breakpoints temporarily modify writable
copies of the ROM images held by the FPGA. The U2 reads the C64's own ROM and
has no equivalent writable copy, so visible-ROM breakpoints are unavailable
there. RAM breakpoints and register capture use the same shared implementation
on both machines.
Platform interface
MemoryBackend::create_debug_session()separates the monitor UI from the U64-and U2-specific implementations. Host tests use test implementations, firmware
builds use the U64 or U2 implementation, and the UI interacts only with the
shared
DebugSessioninterface.Cleanup and mode handling
Temporary instructions, vectors and working memory are restored on every exit
path: normal exit, timeout or cancellation, reset, monitor close,
RUN/STOP,C=+OandC=+X.In Overlay mode the debugger prepares the resume code before restoring modified
program bytes, so the running CPU never meets partially restored code. Freeze
mode temporarily resumes the C64 while an instruction executes and then freezes
it again; Telnet and Overlay do not need that cycle.
Implementation
machine_monitor.ccmachine_monitor_debug_impl.incmonitor_debug.{h,cc}monitor_breakpoints.{h,cc}monitor_debug_session.hmonitor_debug_brk_session.ccmonitor_debug_u64.ccmonitor_debug_u2.ccTesting
Host tests
The three host binaries under
target/pc/linux/machinemonitortestall pass.software/test/monitor/machine_monitor_debug_test.ccholds 189 cases coveringinstruction prediction, breakpoint handling, execution controls, Debug/Edit
interaction, cleanup, timeout recovery, Freeze and Overlay behaviour, Step Out
tracking, and U64 BASIC/KERNAL stepping. Every firmware defect listed below has
a case there that fails without its fix.
These need no device and all pass:
Hardware suites
Both machines run the firmware this branch builds, reporting
git_commit_hash cfd27881, the merge commit. Nothing undersoftware/changes after thatcommit, so every later commit is test harness only and the firmware under test
is this branch's firmware.
Every run below is a single command, given in full so it can be repeated.
Ultimate 64 Elite I: all green
machine-code-monitor, Overlaymachine-code-monitor, Freezemachine-code-monitor, Telnetmachine-code-monitor-debugmachine-code-monitor-matrixU2+L in a C64 Ultimate: green except the opcode gate
machine-code-monitor, Overlaymachine-code-monitor, Freezemachine-code-monitor, Telnetmachine-code-monitor-debugmachine-code-monitor-matrix, the 15 cellsmachine-code-monitor-matrix, the closing 1000-opcode gateThe cartridge skips are not failures. They are described under Test harness
changes, in "What a cartridge cannot be asked to do".
Still to be settled: the 1000-opcode gate on the cartridge
The matrix's fifteen cells pass on the cartridge on every run. The gate that
follows them steps about 1440 instructions and compares the debugger's register
footer against an independent 6502 interpreter, and it requires zero
unrecovered failures. On the cartridge it produces about one per run.
The cause is measured, and it is the transport rather than the debugger. Every
keystroke and screen read for a cartridge target crosses the C64 Ultimate's
keyboard matrix and the cartridge bus. Driving the monitor's Jump prompt
directly, 2 characters were lost in 89 typed arguments, about 445 keys. The
gate's own counter agrees: a clean run records
step_resends: 16against 1440steps, so the harness silently recovers about sixteen losses per run and
occasionally one gets past the resend budget.
Six consecutive runs each produced one unrecovered event, and a different one
each time: a liveness sample taken mid hand-back, a register footer read while
the row was half written, twice, a launch that stopped somewhere other than the
entry point, twice, and a step that reported no progress. Each was fixed on its
own terms and none recurred. The Ultimate 64, which has no matrix in its input
path, passes the same gate twice with 45 of 45 cells.
So the remaining question is not a defect to find but a contract to choose for
this gate on a cartridge:
machine:inputthemselves and reportSKIPPED_UNSUPPORTEDon a cartridge with the measured loss rate as thereason, which is how the matrix already treats the twelve cells a cartridge
cannot run.
--opcode-runso the expected unrecovered countsits well below one.
reported.
Nothing else in this PR is waiting on that decision.
Not re-run this round
machine-code-monitor-regressionis a selection out of the other three suites.All three were run in full instead, so it was not run again. Its
entry-footerscope is discussed under Outstanding work.
The Telnet monitor lane is markedly slower than Overlay or Freeze on both
machines because every screen read is a full 60-column redraw over the network.
On the cartridge every keystroke additionally crosses the C64 Ultimate's
keyboard matrix and the cartridge bus.
What the matrix covers
monitor_debug_matrix_test.pyis the main release test. It exercisesThe two traversal modes matter because real debugging crosses memory-region
boundaries rather than entering each region from a fresh bootstrap.
ram-rom-ramstarts in RAM, enters BASIC ROM at$BC0F, and returns to RAM.ram-rur-rom-ramtraverses RAM, RAM under ROM, visible ROM and back whileswitching
$01between legs. A direct RAM-under-ROM to visible-ROM traversal isnot practical, because changing
$01while executing from the banked regionimmediately replaces the instruction stream being executed; the fixture returns
to ordinary RAM, changes the mapping there, and then enters ROM, which is how
real 6510 code makes that transition.
Each cell covers Step Over, Step Into, Step Out, Continue To Cursor, breakpoint
Continue, normal Continue and Reset, and validates CPU state and memory effects
against both an independent 6510 interpreter (
mcm6502.py) and VICE. Each runthen executes a separate 1000-instruction live stress run. On the last Ultimate
64 run that gate stepped 2592 instructions with no mismatch; on the cartridge,
2592 instructions, also with none.
Each run appends to a ledger under
doc/research/machine-code-monitor/matrix-runs/, untracked by git, recordingthe commit, start and end times, per-cell status and failure details.
Defects found and fixed
The first full pass on both machines reported green. Re-running it found the
following, each now fixed, with the measurement that showed it.
A monitor edit inside screen RAM did nothing while the freezer was up
C64::peekandC64::pokeserve$0400-$07FFfrom the copy taken at freezetime, because the freezer draws its own menu into screen RAM.
C64::dma_transfer_frozendid not: it served that range from live RAM whilealready serving
$0800-$0FFFand$D800-$DBFFfrom their copies. The monitorreads through the first path and writes through the second, so an edit in screen
RAM went into the menu the freezer was displaying, never appeared in the view,
and was overwritten when the machine was released.
Measured on a U2+L before the fix:
$0400read20,DFwas written, the viewstill read
20andmachine:readmemreadDF. All three ranges now go throughone helper,
frozen_backup_for, so a read and a write of one address reach thesame memory. The monitor suite's boundary sweep reported two write losses at
$0400and$07FFon every pass before the change and none after.A debug session corrupted about 100 bytes of RAM at $0800
U64Machine::poke_visible_preserving_freeze_restorealso wroteram_backup[address]for addresses below$0400.ram_backupholds$0800-$0FFF, sorestore_io()copied those bytes into the user's RAM when themachine was released. The debugger's handler, trampolines and vectors live in
$0300-$03FF, and every parked resume writes$0000and$0001through thispath, so
$0800and$0801, the first two bytes of a BASIC program, took theCPU port values.
Measured on an Ultimate 64: a pattern was written to
$0800-$0FFFwith themachine running, one debug session was opened and closed, and 101 bytes had
changed, starting
$0800 A5 -> 2Fand$0801 A4 -> 37. The helper is removedand its callers use
poke_visible, which needs no special case becauserestore_io()restores nothing below$0400. The same measurement after thechange reports 0 bytes changed, and
Exit-liveness: a debug session leaves $0800-$0FFF alonenow asserts it.A visible-ROM step behaved differently over Telnet
The interpreted and trampolined route for a linear step in visible ROM was
selected by
!debug_owner.remote, so Overlay and Freeze took it and Telnet didnot. Over Telnet the same step wrote a
BRKinto the KERNAL image and releasedthe live CPU onto it. Which user interface owns the session does not change
which CPU runs the step or which port serves its fetch, so the transport test is
removed and every transport takes the same route.
test_remote_visible_rom_linear_step_keeps_brk_out_of_rom_imagefails with thetransport test present.
Leaving the monitor on a cartridge left the 6510 held
run_machine_monitor.ccreleased ownership after a Go only under#if defined(U64). Every other target calledrelease_host(), which takes downthe UI objects but does not unfreeze: only
release_ownership() -> C64::unfreeze() -> C64::resume()writesC64_STOPbackto 0. On a cartridge the monitor's UI is the freezer, so leaving Debug with a
Gdropped the interface while the machine stayed held, and a host reset could not
reach the cartridge's own stop. The guard now matches the deferred-Go block a
hundred lines above, which already handed back on every target.
A reset left the session believing its handler was still installed
A reset rebuilds the KERNAL's soft vector table, which puts
$0316/$0317backon the KERNAL's own BRK handler at
$FE66. The session went on recording thatit had installed its own handler there, and
save_and_install_handler()returned early whenever
handler_installedwas set. The next launch thereforearmed a
BRKthat trapped into the KERNAL, nothing set the sentinel, and thelaunch sat until the five-second watchdog and reported
DEBUG TIMEOUT.Measured on an Ultimate 64:
$0316/$0317were written with5D 03, thedebugger's handler address, a
machine:resetwas issued, and they read back66 FE.A reset reaches the session by two routes, and both are fixed.
request_reset_cancel()handles the reset the monitor performs itself; when thesession is parked it deliberately writes nothing, because those addresses hold
the code the CPU is executing, but it also left the installed-flags set, and that
branch now calls
forget_installed_state(). The reset button, a host reset andSYS 64738never reachrequest_reset_cancel()at all, sosave_and_install_handler()now reads$0316/$0317and reinstalls the handler,its trampoline and the vector when they no longer name the handler, instead of
trusting the flag.
test_reset_while_parked_makes_the_next_launch_reinstallandtest_reset_behind_the_session_makes_the_next_launch_reinstallcover the tworoutes and each fails without its change.
The breakpoint list moved the debug context
Picking a slot with a digit key called
apply_go_localwith an address thecursor was not on, which sets
debug_cursor_override, which makes the nextContinue start at that address instead of resuming the captured stop. Pressing
RETURN on the same slot did not, because it set the cursor first. Reading the
breakpoint list must not cost the session its context, so both keys now place the
cursor before the jump. Two host tests pin the two paths to the same behaviour.
The header ran Poll and Dbg together
Pollis drawn atwidth-9and is four characters;Dbgis drawn atwidth-8. With both active the header readPDbg.Pis the breakpoint keyonce Debug owns the keyboard, so poll mode could not be turned off from there
either. Entering Debug now clears poll mode, which also stops a parked machine
being redrawn on a timer.
The original Ultimate II paid for a monitor it does not build
C64::freeze()calledcapture_cpu_port_via_nmi()on every non-U64 target.That reading exists for the monitor's banking display, and
target/u2builds nomonitor sources. The five targets that do build the monitor now define
MACHINE_MONITOR=1and the call is compiled out elsewhere. The U2 image dropsfrom 810,872 to 809,304 bytes against a 811,008-byte partition, so this branch
costs that target 120 bytes rather than 1,688, and its freezer no longer pays a
stop, NMI and restore round trip it cannot use.
Test harness changes
A cartridge loses one of two identical key taps
Keys for a cartridge target are queued as taps on the keyboard matrix of the
computer it is plugged into, and the cartridge reads that matrix once per call to
Keyboard_C64::getch(). Between two taps of one key inside a single request thematrix is empty for one 20 ms tick. A scan that does not land in that gap sees
the same matrix position on both sides of it, reads one key held down, and
suppresses the second tap as auto-repeat. Two different keys are never confused,
which is why the existing key-injection instrument never saw this: it walks an
alphabet.
Measured on
u2@c64u, typing into the monitor's ASCII edit page and reading thebytes back, 16 keys a run:
aabbccdd...aabbccdd...aabbccdd...aabbccdd...abcdefgh...RestBackend._post_eventssplits a batch wherever an event repeats the onebefore it, waits for the queue the previous run left behind, then waits
pacing.SPLIT_REPEATED_KEY_GAP_SECONDSbefore posting the next. Waiting for thedrain is what carries this: splitting alone, with the next request posted
immediately, still lost 15 of 64. The gap is set to 50 ms, since the sweep shows
nothing is bought by waiting longer. A device target has no matrix in the path
and sends the events as one request.
What a cartridge cannot be asked to do
Twelve of the matrix's fifteen cells cannot run on a U2+L, and the suite used to
report them as failures.
U2MemoryBackend::supports_cpu_banking()returns false,because a DMA read of
$0001returns a mirror refreshed only at reset, so themonitor has no bank view from which to place a RAM-under-ROM breakpoint.
supports_visible_rom_patching()is overridden to true only in the U64 backend,so a
BRKcannot be written into a visible ROM image. The firmware refuses both,with
BRK $E000 IN ROM BLOCKS DEBUG, and that refusal is correct.Four memory modes depend on one of those capabilities:
ram-under-romandromplace their entry breakpoint where the cartridge cannot patch, and
ram-rom-ramand
ram-rur-rom-ramstep into theJSR $BC0Fin BASIC, which sets a breakpointon a call target inside the ROM image and is answered with
DEBUG NOT SUPPORTED.Those cells now finish as
SKIPPED_UNSUPPORTEDwith the reason printed, and areexcluded from the failure tables rather than counted as findings. The decision is
keyed on the split-session flag, so a single-host run skips nothing: the Ultimate
64 still runs all 45 cells.
Two runs at once
Running the two machines' suites concurrently used to fail.
run-testsnumbersthe runs it starts itself in
E2E_PORT_SLOT, but numbers them from zero perinvocation, so a separately started Ultimate 64 run and U2+L run were both
offered slot 0 and their VICE oracles fought over
127.0.0.1:6518. The loserreported
VICE oracle setup failed: timed out, which reads as a device fault andis not one.
The slot is now claimed rather than assumed.
E2E_PORT_SLOTis the preferredstarting point, so a lone run keeps the layout it was given, and a run that finds
that slot taken moves to the next free one. The claim is an exclusive lock the
kernel releases if the run is killed, and the ports are probed as well, because a
VICE left behind by a killed run holds its port without holding a lock. Verified
directly: four concurrent claims take four distinct ports, and a deliberate port
squatter is stepped over.
Two further faults surfaced only under the load of a second suite, and both were
the harness misreading a screen it had failed to fetch rather than a debugger
defect.
select_monitor_bank()pressed its bank key even when the status readcame back empty, so it cycled the view while blind and walked away from the bank
it had been asked for; an unreadable screen is now retried as a read, and only a
parsed status spends a keypress.
STATE_SETTLE_TIMEOUT_SECONDSwas a fixed 12seconds, after which
_await_snapshot()returns the stale snapshot and thecaller reports a missing footer; it can now be raised for a loaded run with
MCM_STATE_SETTLE_SECONDS.Keeping the oracle in step with the device
The opcode-volume gate compared the debugger's register footer against the
independent 6510 oracle after waiting for the footer's PC to reach the oracle's
PC. A JSR nest passes through every address twice, once descending and once as
the RTS chain unwinds, and the two visits differ by the frame the JSR pushed, so
matching on PC alone could accept the wrong visit and then report the other
one's registers as a divergence. Measured on the cartridge: one mismatch in each
of five consecutive runs at about 2500 instructions a run, against none in 1440
steps of the same seed on an Ultimate 64. The wait now requires the stack pointer
the step must land on as well as the PC, and a mismatch is confirmed by one
further read before it is reported, since a real divergence survives a second
read and a row caught mid-update does not. The gate then reported 0 errors over
2592 instructions.
Two explanations were tested and discarded first. A duplicated keystroke was
ruled out by counting re-sends, which the summary now records: every mismatch
happened with
step_resends: 0. A guard that confirmed every footer read, ratherthan only a mismatching one, produced more failures than it prevented and was
reverted.
Diagnostics
wait_for_sentinelcallslog_launch_timeout_state()before returningDBG_TIMEOUT. The U64 backend printsC64_STOP,C64_MODEandC64_CLOCK_DETECT, which separate a launch whose interrupt request neverproduced an edge from one whose machine was never released. The three registers
are read without side effects, on a path that has already failed.
Known limitations
supported.
fixed maximum wait time.
has no writable copy of the C64 ROM for the debugger to modify temporarily.
the NMI vector. While the machine is frozen that reading cannot go stale,
because the CPU is halted; on a machine left running it is the port as sampled
when the monitor opened.
Outstanding work
One suite failure is open, on the cartridge only. The
machine-code-monitor-regressionsuite runs a split-session-only scope calledentry-footer, which asserts the debug footer on the monitor's first frame forsix CPU-port states across four VIC banks. On the U2+L, 12 of its 24 cells fail:
the three port states that leave the KERNAL banked out (
$30,$34,$35)fail on all four VIC banks, and the three that leave it mapped (
$33,$37,and
$37with a DDR variant) pass on all four. The failure isentry breakpoint: footer PC did not reach C000, so the entry breakpoint doesnot trap when the program runs with the KERNAL banked out.
This is not a regression from this round. The same scope failed 4 of 24 cells on
an earlier run in this round, and 12 of 24 now. The change is in the harness
robustness described above, not in the firmware, and the scope has not been
green on the cartridge at any point in this round. It is reported here rather
than skipped, because unlike the twelve matrix cells this scope is not blocked
by a missing hardware capability:
$FFFEwith the KERNAL banked out is RAM, anda cartridge can patch RAM. Whether the fault is in the hard-vector install or in
the U2's sampled CPU-port reading has not been established, and no claim is made
here about which.
The Ultimate 64 does not run this scope: it exists specifically because the
matrix skips monitor bank selection on a cartridge.
Two rig endurance problems also remain, neither specific to this branch and both
recoverable:
back-to-back matrix runs and needs a JTAG redeploy.
testing, which surfaces as unrelated-looking failures across the debug
checks, and needs a host power cycle. Measured: the two Step Out checks the
matrix preflight runs failed four times in a row before one, and passed six
of six immediately after.
The runs reported above were each started on a freshly recovered rig for that
reason.