* feat(app): bounded halt pump on DebugCore; the UI mirrors the halt DebugCore owns the halt: pump() serves the debug lane, waits at most kPumpWait (4 ms) on a debug-lane-only wait and returns VJ_DBG_RESUME_NONE to GDBHalt's own loop, which keeps the socket service and the halt timeout. The UI writes its verdict tagged with the halt serial it saw, so a verdict for a dead halt never resumes the next one; frameEnded() closes a halt the core left on gdb's say-so. DebugSession mirrors HaltState once per UI frame (syncHalt) and fires onHaltUi / onResumeUi on serial edges; attach/detach/breakNow are ordered through the lane and a pending detach. Inline keeps the nested UI frame. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a STEP the core never re-halts must not leave the UI mirror halted pump() returns STEP without bumping the halt serial, so the bar stays up while the core steps. If the frame ends before the core re-halts (the stepped instruction ended it, or the target had stopped), frameEnded() found nothing halted and the mirror stayed halted for good; inline, stepEmulation() is gated on halted(), so the frame that would re-halt never ran and F9 / Detach wrote verdicts nothing took. DebugCore now remembers a STEP in flight (cleared by the next halt entry or setIface) and frameEnded() closes it like a foreign resume. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): verdicts and detach only against the core's live halt After a STEP verdict the halt serial is unchanged but the core is running until it re-halts, so DebugSession::detach() offered a DETACH for a serial the re-halt then dropped, left detachPending_ set (attach / Break refused) and, with hardcore turning on in that window, left the core attached. resume / step / detachAndResume now require the core's halt to be live (parked, same serial); detach and detachAndResume fall back to the lane detach otherwise, and that post also releases the pump if it runs inside a live halt. haltedFrame() syncs the mirror first so the first nested frame meets the current halt; the inline resume hook keeps headless audio unpaused as before. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): the emulation thread pumps the halt; quitting ends it Threaded, the pump's wait is App::haltWait (serve the debug lane for at most 4 ms, refill the views when a request is pending); frameEnded() runs after every retro_run so a halt gdb ended is closed on our side (inline: from syncDebug outside a frame). beginQuit() sets quitting_, wakes the pump and waits at most 200 ms for the detach the pump answers with, before the auto-save, the SRAM flush and the 3 s unload; closing the window inside an inline halt takes the same path. haltedFrame runs only inline. The halt bar says "HALTED by gdb" for a halt the app did not ask for; a halt at the CPU window's own temporary breakpoint (run to cursor, step over) is ours (DebugSession::ownsBreakAt). Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): calls and frame advances give up when a halt begins; content changes detach first EmuThread::call and advance take a give-up predicate: a call queued behind a frame that halts returns instead of parking the UI behind the busy spinner; advance registers its budget on the debug lane and waits with the same predicate. Every UI-thread call passes one (App::emuCall). Threaded, open / close / reset / disc detach the debugger first and then proceed; save / load state and boot refuse with a toast; frame-lane posts wait for Continue and the halt bar and the Core options tab say so. Inline keeps refusing (the call would run inside retro_run). RetroAchievements replies ride the debug lane, so none is ever cancelled. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a given-up disc follow-up retries; close reports a re-halt; deterministic give-up and re-drain tests followDiscSwap advances seenDiscSwaps_ only when its call ran, so a halt no longer loses the RA / cheats follow-up. closeContent says so when its detach predicate gave up. The netplay headless step stops re-budgeting a frame whose advance gave up. testCallGiveUpLosesToStart covers the compare-and-swap-lost branch of call(), and testAdvanceBudgetDoesNotOvertakeFramePost covers the loop's second frame-lane drain; the 300-iteration smoke loop is gone. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): the halt and the GDB stub work on the emulation thread; a silent host behind every halt; hardcore locks the stub The phase 1 limits are gone: Break / Step / breakpoints work threaded and virtualjaguar_gdb_stub is a plain option again. Whenever the stub is enabled the app attaches its host on the emulation thread inside the load (before the first frame, so a gdb_wait boot halt is seen) and again after any frame it is missing, so every gdb halt flows through the app's pump: the halt bar shows it, calls give up, quitting detaches. RetroAchievements hardcore locks the stub to disabled at load, and a hardcore flip with the stub open reloads the game so the lock reaches the core (phase 3 plan, decisions 2 and 3). Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): B4 review: pre-frame silent host, gdb halts survive headless inline, Detach clears breakpoints - emuRunOneFrame re-attaches the silent gdb host before the frame, so a gdb halt right after a Detach is hosted - headless inline haltedFrame releases only app halts and quit; gdb's halt keeps the core's socket loop serving - Detach drops the session breakpoint list with the core's table - Settings > Emulation thread hint reflects that the halt and stub work threaded - locked-option warning is generic; a hardcore restart waits out an inline halt Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): the halt and the GDB stub end to end on the emulation thread debug_e2e_threaded drives break / step / continue / detach / hardcore / quit from a UI thread that never blocks over 50 ms, pins the option post that applies after Continue, the save state that gives up when a halt begins, a posted lane detach run inside a live halt, the 3 s join, and a gdb_wait boot halt with no client (Detach, quit). gdb_threaded_halt finds a headless run halted at boot from a gdb client, reads memory, continues, and quits the app mid-halt, threaded and inline, plus an inline hardcore variant: SIGINT / SIGTERM end a headless run cleanly (installed after SDL_Init, headless only). The headless inline loop now closes a gdb halt after each frame (frameEnded + syncHalt), without which the hardcore reload retry stays blocked. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): B5 review: the cancelled save is proven absent after frames run; free ports; signal handler resets debug_e2e_threaded waits for frames after the halt ends before asserting the cancelled save left no file (it is held while halted, so the old check was vacuous) and saves to a different file; the gdb_wait sections probe a free port; the test also fails on ThreadSanitizer. The signal handler restores SIG_DFL after storing the flag (a second signal kills a wedged run) and g_signalQuit is asserted lock-free. gdb_threaded_halt.py's watchdog kills live children; a missing Python3 is reported at configure time. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): the halt and the GDB stub on the emulation thread Halt model, Detach, hardcore lock and reload, gdb_wait, headless signal quit, testing; README, testing.md, design section 6 (2026-10-22 as-shipped notes), plan 3b task list. Includes the MSVC fix: debug_e2e_threaded includes <ws2tcpip.h> for socklen_t. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): quitting while a gdb client holds a halt no longer hangs The pump answered a quit with DETACH, which drops the app's host inside the core. A gdb client's breakpoint that is still armed (a hot loop) then fires again at once, with the socket open and no host: the core parks in GDBHalt() for the client, and the quit's flush, autosave and unload wait on it forever. The pump now answers CONTINUE and the host stays attached (unloading drops it). Every wait in the quit epilogue is bounded (flush 1 s, autosave 1.5 s, skipped while the core is still halted); the unload keeps its 3 s backstop. Also: re-attach the silent gdb host before each frame (inline stepEmulation, headless inline loops), comment/doc fixes, EmuThread::stop precondition, and the three gdb tests' PASS regexes lose their ^ anchor. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a hardcore restart that gives up stays pending and retries Achievements::pump cleared resetPending_ before the restart and ignored its result. Threaded, the restart can give up when the core halts again (a gdb client with a breakpoint still armed): hardcore was then active without a clean restart and the open GDB stub bypassed the hardcore lock. The restart now stays pending until it takes. reset() returns whether it ran. A give-up with the game still loaded (and, for the reload, the stub still open) retries at the next pump; a restart that failed to load the game, or closed the stub, is dropped. The cheat reset is posted once and the restart is counted once (--ra-expect-reset), the give-up is logged once. Tests: VJ_TEST_RESTART_GIVEUPS=N makes the first N restarts report a give-up (a headless run cannot stage the re-halt); gdb_hardcore_flip_giveup_* (reload path) and ra_hardcore_reset_giveup_* (reset path), inline and threaded. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a signalled threaded --neuro headless run still prints the Neuro report The signal-quit block returns before the TIMING/report section; report first (it only touches UI-thread state) so a SIGTERM'd --neuro run behaves like a finished one. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): gdb_* tests take their port from the OS at run time (#967) The six tests with hard-coded ports 23457-23463 (gdb_enabled, gdb_enabled_threaded, gdb_hardcore_flip_*, gdb_hardcore_flip_giveup_*) all failed on the hosted Windows ARM64 runner with 'failed to open port': every one of the distinct ports was refused, which points at a reserved/excluded range, not a collision (the Python gdb tests, which already pick a free port, passed there). tests/with_free_port.py binds 127.0.0.1:0, reads the port back, substitutes {PORT} in the command and, with --config-ini, writes the saved stub setting for the hardcore tests. The assertions and ctest regexes are unchanged. Registered only with Python3 (STATUS message otherwise). RESOURCE_LOCK vj_gdb_port on every test that binds a stub port closes the bind-0 TOCTOU window. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): start Winsock before the core loads so the GDB stub can bind on Windows (#967) Every gdb_* test that opens the stub failed on hosted Windows x64 with "[GDB] stub enabled but failed to open port N", on fixed and on OS-chosen free ports alike. The core's GDBSockOpen (src/debug/gdbsock.c) calls socket() without WSAStartup, because its libretro host (RetroArch) starts Winsock. The app never did on that path (neuro and netplay only start it for themselves), so socket() failed with WSANOTINITIALISED and the stub stayed disabled. The feature was therefore broken for users on Windows too, not only in tests. platform::initNetworking() now runs WSAStartup once, right after SDL_Init and before any core load, headless runs included. A no-op on macOS and Linux. Winsock is reference counted, so neuro's and netplay's own WSAStartup stay harmless. Also corrects the comments that blamed reserved port ranges. Refs #967 Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): with_free_port.py hands the app explicit stdout/stderr (#967) The Packages / Windows x64 job builds the GUI-subsystem exe (VJ_APP_WIN32_GUI). There, gdb_enabled, gdb_enabled_threaded and the gdb_hardcore_flip* tests missed their PASS regexes. Popen(cmd) left the child's standard handles to inheritance, so Windows started the GUI-subsystem app with no stdout. console_attach.cpp then attached the runner's console, and the output never reached ctest's pipe. The wrapper now passes stdin=DEVNULL and stdout/stderr explicitly, so the output stays on the pipe. Tests that ctest launches directly already get redirected handles and were unaffected. Refs #1075 Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: dry <dry@x>
343 KiB
Emulation thread, Phase 3 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: With Settings > Interface > Emulation thread on, nothing is switched off any more: every Tools window, the debugger halt (F9/F10, breakpoints, step), the GDB stub and Neuro work on the emulation thread, and nightly builds default the setting On for users who never chose.
Architecture: Four PRs, each keeping one owner thread per piece of state.
- 3a, debug views. The command queue gets a second lane,
Lane::Debug, that the emulation thread drains at frame boundaries and from inside a halt. Every Tools window stops callingvj_debug_ifacefrom the UI thread: periodic reads (memory, CLUT, registers, disassembly, the OP snapshot, the blit-log tail, the scanner's DRAM) becomeDebugViewsrequests the emulation thread copies into per-window double buffers after each frame; one-shot actions (poke, set_reg, breakpoints, arm, log enable, pack reload) go throughEmuThread::debugCall. Inline, the same copier runs on the same thread, so the windows have one code path. - 3b, the halt and the GDB stub.
DebugSessionsplits into a UI-thread mirror andDebugCore, the emulation-thread owner of the interface and the pump. The pump is a bounded wait (<= 4 ms) that serves the debug lane and returnsVJ_DBG_RESUME_NONEto the core's ownGDBHalt()loop, which keeps servicing the socket and the halt timeout. Verdicts are an atomic the UI writes.App::haltedFrameruns only inline. Frame-lane commands wait for Continue ("applies after Continue"); content-changing calls detach first;call()can give up when a halt begins. The app attaches a silent host whenever the stub is enabled, so a gdb halt is visible and shutdown can detach it. - 3c, Neuro. The UI publishes Neuro's whole input program through
InputSnapshot; the core's input poll runs anAgentPlayerreplica one step per emulated frame and reports progress back. RAM watches areDebugViewsrequests. - 3d, the flag.
VJ_BUILD_CHANNEL(release|nightly|dev) setsVJ_EMU_THREAD_DEFAULT; the default applies only whileui.emu_threadis unwritten.
Tech Stack: C++17 (app/), SDL3, Dear ImGui, CMake/CTest, the statically linked C89 core (unchanged), ThreadSanitizer, GitHub Actions, the Python gdb probes in test/tools/.
Spec: docs/app-emulation-thread-design.md (sections 5, 6, 8 and 10 step 3, with the 2026-10-20 amendments). Earlier plans: docs/app-emulation-thread-plan-phase1.md ("Phase 1 boundary", "Follow-up plans"), docs/app-emulation-thread-plan-phase2.md ("Follow-up plans"). docs/neuro.md ("Emulation thread"). The scope survey behind the split (2026-10-20, develop @ abc9054e) found: every Tools window except the audio scope calls vj_debug_iface from the UI thread (the scanner even scans the raw 2 MB pointer CoreHost::memoryRegion hands out, which is owner-only); Neuro's Agent::frame() runs inside the core's input poll while the rest of the agent is UI state, and its watches read RAM through a raw pointer; cheats (including #1052), TV mode, screenshots, frame hashes and achievements were already correct.
Decisions to confirm
The maintainer asked for one Phase 3 PR; this plan proposes four (3a -> 3b and 3c in parallel -> 3d), 3d merging last. Each decision below is adopted provisionally and goes to the maintainer at review.
| Decision (proposed) | Recommendation and why | |
|---|---|---|
| 1 | Four PRs: 3a debug views, 3b halt + gdb, 3c Neuro, 3d nightly default | Yes. One PR is ~30 files / ~3k LOC across 8 tool windows, a state machine, a protocol path and a build flag. The order is forced (the pump serves the views; Neuro's watches reuse the copier); 3d alone is the revertable flip. |
| 2 | virtualjaguar_gdb_stub locked to disabled while RetroAchievements hardcore is active |
Yes. DebugSession::attach() needs enabled(), which hardcore denies, and decision 3 needs a host behind every halt. Today nothing blocks the stub under hardcore; a debugger is a cheat tool. |
| 3 | The app attaches a silent host whenever the stub is enabled and content is loaded |
Yes. Every halt then flows through the app's pump: the halt bar shows "HALTED by gdb", calls give up, quit detaches. Cost: one stored pointer. Side effect (already true inline): quitting clears gdb's breakpoints. |
| 4 | VJ_BUILD_CHANNEL=dev defaults the setting Off |
Yes. PR artifacts and local builds then behave like releases; windowed runs opt in with --emu-thread on. |
| 5 | The flatpak job seds -DVJ_BUILD_CHANNEL=release to nightly in the manifest before flatpak-builder |
Yes. The manifest's config-opts are fixed and committed; a sed in the job is the only per-channel knob. |
Global Constraints
- C++17 in
app/. The core stays single-threaded C89 and is not touched: no file undersrc/orlibretro*.cchanges. The halt loop insrc/debug/gdbtarget.candsrc/debug/vj_debug_iface.hare consumed as they are. - Only the emulation thread calls into the core: every
retro_*call, everyvj_debug_ifaceentry point, every read or write of core memory. A[core_host] owner violation:line in any test log is a failure. - Four PRs, each branched off
libretro/develop(nevermaster), each targetingdevelopwithRefs #967in the body (phases 4 and 5 remain, so noCloses). 3b and 3c branch after 3a merges; 3d branches after both merge. Worktrees under~/Workspace/Provenance/vj-wt/(emu-p3a,emu-p3b,emu-p3c,emu-p3d), removed when their PR merges. - App CMake builds never set
DEVELOPER_DIR. The configure line isenv -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release; theDEVELOPER_DIR=/Library/Developer/CommandLineToolsprefix is for the core's Makefile build only. - Never launch the app with a window on the maintainer's Mac. Windowed checks and measurements run on the Mac Studio only, through
~/Workspace/Provenance/vj-eval/remote/vjr.sh(bash $VJR run <tag> env -u DEVELOPER_DIR bash -lc '<script>', ssh aliasstudio-runner), afteruptimeshows a load under 2. Headless runs are fine anywhere, but checkuptimefirst on the Mac too and do not start builds while it is above 10. - The setting stays Off by default in every build through 3a, 3b and 3c. 3d flips the nightly default and nothing else. Headless runs stay inline unless
--emu-thread onis given (the smoke hashes are inline hashes). - Every existing test keeps passing unchanged in inline mode. The three limit tests (
emu_thread_limits,gdb_locked_threaded,neuro_off_threaded) are rewritten by the sub-phase that lifts each limit, never deleted without a replacement that pins the lifted behaviour. - New tests that touch two threads carry
LABELS emu_thread(the ThreadSanitizer job runsctest -L emu_thread).app/tests/tsan.suppdoes not grow: a report is fixed by routing the reader through a published value. - Conventional commits (
feat(app):,fix(app):,test(app):,ci(app):,docs(app):), each message ending withCo-Authored-By: Claude Opus 5.5 <[email protected]>. Commits are signed through 1Password; on a signing error wait and retry, never disable signing. - Delete with
trash, orcommand rm -f. Never a barerm,cpormv(aliased to-i; with no TTY they hang).
Review Focus
Six failure modes most likely to slip past the per-task tests. Each is pinned by the test named, in the task that owns the code.
- A
call()posted an instant before the core halts hangs the UI. The call waits behind the halted frame; the busy spinner replaces the picture and cannot draw ImGui, so the user cannot press Continue. Pinned byemu_thread_unittestCallGivesUp"the call returned R() ... the cancelled task never ran" (Task B3) anddebug_e2e_threadedsection 4 "a save state requested just before a halt returns false within 300 ms" (Task B5). - A view buffer read across a content switch shows the previous game. The copier filled the buffer for game A; the window draws it after game B loaded. Pinned by
debug_views_unittestEpoch"a buffer filled before bumpEpoch() is not readable after it" (Task A2) anddebug_views_threadedcase 4 "after a reload the Memory window's first read is the new ROM's bytes" (Task A9). - A Neuro press lasts a different number of emulated frames threaded vs inline.
press_buttons frames=3must press on exactly 3 core polls at any display rate and under fast-forward, and an action appended mid-play must neither replay nor drop frames. Pinned byneuro_unittestAgentPlayer"AgentPlayer presses for exactly N polls" and "append and hold change mid-play equal one uninterrupted queue" (Task C1),neuro_core_e2e"9 polls, free-running, at 600 Hz" (Task C2) andneuro_loopback_threadedagainst the inline run's frame-numbered vector (Task C4). - The nightly default overrides a user's explicit Off (or a release overrides an explicit On). A headless run is always inline, so the stored-choice log line, not "this run", is what the smoke reads. Pinned by
emu_thread_default_unit"pref 0 is Off and pref 1 is On in every channel; only pref -1 follows the channel" (Task D1) andemu_thread_default_smoke_false/_true/_unsetreadingui.emu_threadfrom a data dir, derived from the configured channel and run once under the nightly configure (Task D2). - The debug lane is drained while the frame lane is mid-frame outside the pump, or the frame lane is drained inside the pump (a load inside
retro_run). Pinned byemu_commands_unittestLanes"drain(Lane::Debug) leaves the frame lane untouched and vice versa" (Task A1) anddebug_e2e_threadedsection 3 "a frame-lane option post made while halted applies only after Continue;optionValueForreads the old value until then" (Task B5). - Quitting while halted exceeds the 3 s join (the pump never sees the detach:
quitread non-atomically, the lane not notified, or a gdb-held headless run with no way to quit). Pinned bydebug_session_unittestDetachEndsPumpWithinOneWaitandtestAbortHaltDetaches(Task B1),debug_e2e_threadedsection 7 "stop() returns true within 500 ms while halted" (Task B5) andgdb_threaded_haltstep 5 "SIGTERM while gdb holds the halt: exit 0 within 3 s" (Task B5). - The app stays "halted" after gdb continued (gdb's
c, a dropped socket or the halt timeout leaveGDBHalt()without a verdict of ours; the mirror never clears, F9 then resumes the next halt). Pinned bydebug_session_unittestFrameEndedClosesForeignResumeandtestStaleVerdictDropped(Tasks B1, B2).
Resolved spec ambiguities
| Spec says | Phase 3 does | Why |
|---|---|---|
| "blocks on a condition variable for Continue / Step / Detach" (section 6) | The pump waits at most DebugCore::kPumpWait (4 ms) per call and returns VJ_DBG_RESUME_NONE; the core calls it again. |
GDBHalt() (src/debug/gdbtarget.c) owns the socket service (GDBPumpOnce), the halt timeout and a 2 ms sleep in its own loop, and the core is not changed. An unbounded wait would starve both. |
"App::haltedFrame and its nested event loop are removed" (section 6) |
haltedFrame is never used when threaded (DebugCore::waitVerdict replaces runUiFrame); inline it stays until phase 5 retires the old loop. |
Inline, the core is parked on the UI thread; the only way the UI can draw is the nested frame. |
| "Commands that cannot run mid-frame ... are held until Continue" (section 6) | Frame-lane posts (options, cheats, controller device, SRAM autosave, key events) sit in the queue until the frame ends: held by construction. Save/load state, boot, frame advance refuse with a toast while halted. Open / close / reset / disc insert / drop / quit detach first, then proceed. | Serializing inside retro_run is not a state. A detach clears every breakpoint, so the frame after it cannot re-halt and the call waits at most one frame. |
| "If both gdb and the app debugger are attached, the first verdict wins, as today" (section 6) | Unchanged in the core; the app attaches a host whenever the stub is enabled (Decision 3) and locks the stub under hardcore (Decision 2). | Without a host the app cannot see a gdb halt: calls would block until gdb continues and quit would wait out the join. |
| "Hardcore mode still detaches the debugger" (section 6) | setHardcore(true) on the UI posts the detach to the debug lane (or writes the DETACH verdict when halted) and closes the tools. |
iface->detach must run on the emulation thread. |
neuro.md: "press through the input snapshot the UI thread publishes, not agentControls" |
InputSnapshot::publishAgent(AgentProgram); the core's poll runs neuro::AgentPlayer one step per frame; InputManager::agentControls is removed, inline and threaded alike. |
A per-UI-frame advance would drift under fast-forward and on 120 Hz panels; a mutex around InputQueue would spread over the whole Agent. |
| "Each open debug window registers a request ... copies just those into that tool's double buffer" (section 5) | DebugViews (app/src/debug/debug_views.h): requests keyed by an id, filled at DebugSession::kRefreshHz while running, on every pump iteration whose serial changed while halted, and on the same thread inline. While no frame runs (paused inline or threaded) the UI's wakeViews() posts one fill to the debug lane, so a paused window never goes blank. |
One code path for both modes; closed windows cost nothing. |
| "peek() never has a side effect" / the scanner's scan pass | The Cheat scanner's scan pass stays a one-shot 2 MB snapshotDram(); it is a DebugSession::read and therefore a callCore (EmuThread::debugCall) that runs on the emulation thread. The raw CoreHost::memoryRegion pointer path is deleted. The live column reads the Dram request. |
A scan pass must see RAM as of one instant; a debug-lane call does, and it blocks the UI for at most one frame plus the 2 MB copy. |
vj_debug_iface.disasm_risc serves RISC only |
DebugViews fills a Disasm request through an injected DisasmFn (setDisasm, installed by the CPU tool: disasmRows, which runs Musashi for the 68000 and disasm_risc for GPU/DSP, all on the emulation thread); without one, RISC targets are served by disasm_risc directly and an M68K request answers ok = false. |
The CPU window keeps one code path for its three tabs and debug_views.cpp does not link Musashi. |
"the halt mirror" and HaltState.serial |
The serial bumps on every entry into a halt (a step's re-halt included) and on CONTINUE / DETACH; the STEP return itself does not bump it. A new halt is `!halted | |
| "Shutdown: detach any debugger halt ... join" (section 7) | App::quitting_ has two writers: the window's quit and SIGINT/SIGTERM handlers in main.cpp (g_signalQuit). A headless run that gdb holds halted sits in emu.advance(); the signal is its only exit, and it exits 0 with headless: quit requested, exiting cleanly. |
gdb_threaded_halt needs exactly that exit; it is also the right behaviour for a Ctrl-C. |
Interfaces (frozen)
Every section uses these declarations verbatim. Names follow the existing headers; where a header is extended, the existing members are unchanged.
app/src/emu_commands.h (A1)
class EmuCommands {
public:
using Fn = std::function<void()>;
using ErrorFn = std::function<void(const std::string&)>;
using TimePoint = std::chrono::steady_clock::time_point;
enum class Lane : uint8_t { Frame, Debug }; // Frame: applied at a frame boundary. Debug: also inside a halt.
bool post(Fn&& fn, Lane lane = Lane::Frame); // false once closed (fn left untouched)
size_t drain(const ErrorFn& onError, Lane lane = Lane::Frame); // one lane, oldest first; throwing commands reported
void waitUntil(TimePoint deadline); // wakes on a post to either lane, or notify()
void notify();
size_t closeAndDrain(const ErrorFn& onError); // both lanes, Frame then Debug each round, then closed
void reopen();
bool closed() const;
bool pending(Lane lane) const; // anything queued in that lane
private:
std::deque<Fn> q_; // Lane::Frame (as today)
std::deque<Fn> dbg_; // Lane::Debug
// mu_, cv_, wake_, closed_ as today
};
app/src/emu_thread.h (A1 adds the lane; B3 adds the give-up)
class EmuThread {
public:
// A frame-lane call (as today). `giveUp` (B3), polled on the waiting thread about every 16-50 ms: when it returns
// true before the task has started, the task is marked cancelled, is skipped when drained, and call() returns R().
// A task that started before the mark still runs to completion (captures must be by value). Never consulted
// inline, on the emulation thread, or after the task started.
template <class F>
auto call(F&& fn, std::function<bool()> giveUp = {}) -> decltype(fn());
template <class F>
bool callFor(std::chrono::milliseconds timeout, F&& fn); // unchanged
// The debug lane (A1): safe inside retro_run(). Inline, or on the emulation thread: runs now. Threaded: queued in
// Lane::Debug, drained at the next frame boundary or by the halt pump (serveDebugLane), whichever comes first.
// debugCall waits like call() (same wait hook after kBusyAfter); it never takes a giveUp.
template <class F>
auto debugCall(F&& fn) -> decltype(fn());
void debugPost(std::function<void()> fn);
// Emulation thread only (B1): what the halt pump does per iteration. Drains Lane::Debug now; if nothing was
// queued, waits up to `maxWait` for a post to the debug lane or a notify(), then drains again. Returns the number
// of commands run. Inline: drains and returns at once (never waits).
size_t serveDebugLane(std::chrono::milliseconds maxWait);
// ...everything else unchanged (start, stop, post, inlineStep, advance, setPaused, setWaitHook, ...)
private:
// loop(): after `cmds_.drain(onError, Lane::Frame)` comes `cmds_.drain(onError, Lane::Debug)`; closeAndDrain
// covers both. A debug post from the UI must wake a parked (paused / idle) thread: post() notifies as the frame
// lane does.
};
app/src/debug/debug_views.h (A2; no ImGui, no SDL, no core include beyond vj_debug_iface.h)
namespace vj {
struct DisasmLine {
unsigned addr = 0;
unsigned len = 0; // bytes (2, or 6 for MOVEI; the 68000's own lengths through Musashi); 0 = could not disassemble
std::string text;
std::string bytes; // the hex bytes of the instruction (what cpu_view's disasmForward returns), "" when len == 0
};
// A5 injects this: runs on the emulation thread inside fill(). Without one, Disasm serves RISC targets through
// iface->disasm_risc and answers ok = false (no lines) for VJ_DBG_TARGET_M68K.
using DisasmFn = std::function<std::vector<DisasmLine>(const vj_debug_iface*, int target, unsigned addr, unsigned rows)>;
struct ViewRequest {
enum class Kind : uint8_t { Memory, Dram, Regs, Disasm, OpSnapshot, BlitLog };
Kind kind = Kind::Memory;
unsigned addr = 0; // Memory: first bus address; Disasm: first instruction
unsigned len = 0; // Memory: bytes (<= 64 KiB per request); Dram: ignored (DebugSession::kDramSize)
int target = VJ_DBG_TARGET_M68K; // Regs, Disasm
unsigned rows = 0; // Disasm: instructions wanted (<= 512)
unsigned since = 0; // BlitLog: first launch index wanted (blit::LogTracker's next_)
};
struct ViewBuffer {
ViewRequest req; // the request this buffer answers (a window compares addr / since / target)
uint64_t serial = 0; // the refresh serial the fill ran under (0 = never filled)
uint64_t epoch = 0; // DebugViews::epoch() at the fill; a content switch bumps it (read() then fails)
unsigned frame = 0; // iface->frame_number() at the fill
bool ok = false; // the iface answered every call
std::vector<uint8_t> bytes; // Memory, Dram (big-endian bus order)
vj_debug_regs regs{}; // Regs
std::vector<DisasmLine> lines; // Disasm
std::vector<vj_debug_op_obj> objs; // OpSnapshot (opCount entries)
int opCount = -1; // OpSnapshot: -1 = armed, not taken yet
unsigned halfline = 0; // OpSnapshot
std::vector<vj_debug_blit> blits; // BlitLog: entries since..head-1 that were still in the ring
unsigned blitHead = 0; // BlitLog: iface->blit_log_head() at the fill
unsigned blitLost = 0; // BlitLog: entries between `since` and the oldest kept
};
class DebugViews {
public:
using Id = uint32_t;
static constexpr Id kNoId = 0;
static constexpr unsigned kMaxMemoryBytes = 65536;
static constexpr unsigned kMaxDisasmRows = 512;
// ---- UI thread (set / clear may also be called from a posted lambda on the emulation thread: one mutex) ----
// kNoId allocates a new id; an existing id is replaced. Returns the id. A request equal to the stored one
// changes nothing (windows re-state theirs every draw); a different one clears the front buffer (read() fails
// until the next fill) and is pending at the same serial.
Id set(Id id, const ViewRequest& r);
void clear(Id id); // no-op for an unknown id
void setDisasm(DisasmFn fn); // A5: the CPU tool's disassembler; empty = the built-in RISC-only fill
// Copies the front buffer. False (and `out` untouched) when the id is unknown, never filled, or filled in an
// older epoch. Cheap: the mutex is held for the copy only.
bool read(Id id, ViewBuffer& out) const;
size_t count() const; // live requests (tests)
// ---- emulation thread (inline: the same thread) -------------------------------------------
// Fills every request whose front buffer carries a serial other than `serial` (or none). Copies the request
// table out first; no lock is held while the iface is called. Returns how many were filled. A null iface fills
// nothing. Fills run in id order.
size_t fill(const vj_debug_iface* iface, uint64_t serial);
bool pending(uint64_t serial) const; // some request is not yet filled at `serial` (the pump asks this)
void bumpEpoch(); // a content switch: every buffer becomes unreadable until refilled
uint64_t epoch() const; // atomic
};
} // namespace vj
Request limits are enforced by set() (a larger len/rows is clamped). Dram requests copy DebugSession::kDramSize bytes from address 0.
app/src/debug/debug_core.h (A3 creates it with the iface wrappers; B1 adds the halt)
The emulation-thread owner of vj_debug_iface and vj_debug_host. Every method runs on the emulation thread (inline: the UI thread). No ImGui, no SDL.
namespace vj {
struct HaltState {
bool halted = false;
int target = -1; // VJ_DBG_TARGET_*
unsigned pc = 0;
int reason = VJ_DBG_STOP_NONE; // VJ_DBG_STOP_*
uint64_t serial = 0; // bumps on every ENTRY into a halt (a step's re-halt included) and on CONTINUE /
// DETACH; the STEP return itself does not bump it
};
class DebugCore {
public:
static constexpr std::chrono::milliseconds kPumpWait{4}; // the pump's bounded wait per iteration (B1)
DebugCore(); // fills host_ (size, user, halt_pump thunk)
void setIface(const vj_debug_iface* i); // replaces (and forgets) the old one; drops attach + halt; bumps ifaceGen
const vj_debug_iface* iface() const; // atomic: readable from any thread, written here only
bool loaded() const; // atomic: a debug interface is present (CoreHost::onDebugIface)
uint64_t ifaceGen() const; // atomic: bumps on every setIface() that changes the pointer (the
// core's struct is static, so the pointer alone repeats across a reload)
// 3a only (A3), removed by B1: the session keeps today's halt machine and installs it here.
std::function<int(int target, unsigned pc)> pumpHook; // pump() returns pumpHook ? pumpHook(t, pc) : VJ_DBG_RESUME_DETACH
void markDetached(); // clears attached() without calling the core (the DETACH verdict path)
// ---- iface wrappers (A3): each returns the iface's own result; a null iface returns 0 / false / VJ_DBG_ERR_STATE --
int peek(unsigned addr, uint8_t* dst, unsigned n) const;
int poke(unsigned addr, const uint8_t* src, unsigned n, unsigned flags);
bool getRegs(int target, vj_debug_regs& out) const;
bool setReg(int target, int reg, unsigned v);
bool disasmRisc(int target, unsigned addr, std::string& out, unsigned& len) const;
bool attach(); // false: no iface, or the core refused
void detach(); // clears the core's breakpoints and the host
bool attached() const;
bool breakSet(int target, unsigned addr);
bool breakClear(int target, unsigned addr);
void haltNext(int target);
void opSnapshotArm();
int opSnapshotGet(std::vector<vj_debug_op_obj>& out, unsigned& halfline) const; // -1 while armed
unsigned cryToRgb(unsigned v16) const;
bool buildCryTable(std::vector<uint32_t>& out) const; // 65536 entries, 0x00RRGGBB; false without cry_to_rgb
void blitLogEnable(bool on);
unsigned blitLogHead() const;
bool blitLogGet(unsigned i, vj_debug_blit& out) const;
int texReplaceReload(); // 1 active, 0 inactive, -1 not supported (size check)
unsigned frameNumber() const;
bool region(int idx, vj_debug_region& out) const;
// ---- the halt (B1) ------------------------------------------------------------------------
HaltState halt() const; // any thread: a copy under a mutex
uint64_t haltSerial() const; // any thread: atomic, == halt().serial
// The UI writes the verdict (VJ_DBG_RESUME_CONTINUE / _STEP / _DETACH) and calls notifyVerdict(); the pump
// consumes it. VJ_DBG_RESUME_NONE = none pending.
std::atomic<int> verdict{VJ_DBG_RESUME_NONE};
std::function<void()> notifyVerdict; // App: a debug-lane notify (EmuThread::debugPost of a no-op)
// Threaded (App sets it when the thread is on): serve the lane and wait <= kPumpWait, then return. Inline: empty,
// and the pump runs `runUiFrame` instead (one nested UI frame).
std::function<void()> waitVerdict;
std::function<void()> runUiFrame; // inline only
std::function<bool()> abortHalt; // polled each iteration; true = DETACH (the app is quitting)
std::function<void(int target, unsigned pc, int reason)> onHalt; // emulation thread, first iteration of a halt
std::vector<std::function<void()>> onResume; // emulation thread, on CONTINUE / DETACH (not STEP)
int pump(int target, unsigned pc); // the halt_pump thunk's target; returns the verdict or NONE
// Emulation thread, right after every retro_run() (App::emuRunOneFrame, App::stepEmulation). If halt().halted is
// still true the core left GDBHalt() without one of our verdicts (gdb continued, the socket dropped, the timeout
// fired): clears the state, bumps the serial, fires onResume, and verdict.exchange(NONE) so a verdict written for
// the dead halt never resumes the next one. A no-op when not halted.
void frameEnded();
private:
static int haltPumpThunk(void* user, int target, unsigned pc);
};
} // namespace vj
onHalt and onResume are vectors (as today's DebugSession::onHalt), walked on copies. pump() contract: a new halt is !st.halted || st.target != target || st.pc != pc; on one it records target/pc, reason from iface->halt_reason() when the iface's size covers it (else VJ_DBG_STOP_BREAKPOINT), bumps the serial and fires onHalt(target, pc, reason). Then: if abortHalt() -> DETACH; else take verdict.exchange(NONE); if NONE and waitVerdict is set, call it once and re-take; else if runUiFrame is set, call it once and re-take; if still NONE and abortHalt() -> DETACH. NONE -> return NONE with the state untouched. Otherwise halted = false, target = -1, reason = NONE (pc kept for the bar's last value); DETACH -> attached cleared; CONTINUE / DETACH -> serial bump and onResume; return the verdict. Never waits longer than waitVerdict does. The mutex guards HaltState; the serial is mirrored in an atomic for haltSerial().
app/src/debug/debug_session.h (A3 rewires; B1/B2 add the mirror)
The UI-thread object the windows and menus use. Its public surface is today's (enabled, attach, detach, breakNow, resume, step, detachAndResume, halted, haltedTarget, haltedPc, haltReason, breakpoints, tick, refreshSerial, requestRefresh, read, readU, poke, pokeU, regions, the tools registry) with these changes:
class DebugSession {
public:
// ---- wiring ----
DebugCore& core(); // the emulation-thread half (App wires its hooks)
DebugViews& views(); // the request copier the windows use
// A3: how a method that must touch the core reaches the emulation thread. Empty (inline, tests): runs fn now.
std::function<void(std::function<void()>)> coreCall; // waits (App: emu.debugCall<void>)
std::function<void(std::function<void()>)> corePost; // fire and forget (App: emu.debugPost)
void callCore(std::function<void()> fn) const; // coreCall ? coreCall(fn) : fn() (the windows use these two,
void postCore(std::function<void()> fn); // corePost ? corePost(fn) : fn() never the hooks directly)
// A3: the view fills. fillViews() has NO gate (Neuro's watches depend on it under every state but "no core").
size_t fillViews(); // emulation thread (inline: UI): views().fill(core().iface(), refreshSerial())
void wakeViews(); // UI thread, once per UI frame (App::syncDebug) and by every window draw
// after its set()s: returns unless hasCore() && content loaded; inline
// (corePost empty) fills now; threaded posts ONE fillViews() to the debug
// lane while views().pending(refreshSerial()) (single-outstanding flag)
void onCoreIface(const vj_debug_iface* i); // emulation thread (inline: UI): core().setIface(i) + views().bumpEpoch().
// Touches no UI state (CoreHost::onDebugIface fires on the emulation thread)
void syncCore(); // UI thread (App::syncDebug, first): when core().ifaceGen() moved, forget
// bps_, regions_, the halt mirror; fire onForcedClose("no core") if the core
// went away. setIface(i) == onCoreIface(i); syncCore() (tests, inline)
uint64_t refreshSerial() const; // atomic from A3 on (read on the emulation thread by fillViews)
// B2: the halt mirror. Read by the UI every frame; refreshed from core().halt() by syncHalt().
void syncHalt(); // UI thread, once per UI frame (App::syncDebug): copies the HaltState
// and attached(); when the serial moved fires onHaltUi (now halted) or
// onResumeUi (was halted, is not); a halt that came and went between two
// UI frames fires nothing
bool halted() const; // the mirror (B1 reads core().halt().halted until B2 lands the mirror)
int haltedTarget() const; unsigned haltedPc() const; int haltReason() const;
uint64_t haltSerial() const; // the mirror's serial
bool haltByGdb() const; // set on the halt edge: reason == BREAKPOINT ? !hasBreakpoint(target, pc)
// : !userHaltPending_ (breakNow()/step() set it; the edge clears it)
std::vector<std::function<void(int target, unsigned pc, int reason)>> onHaltUi; // UI thread (toast, pacer reset)
std::vector<std::function<void()>> onResumeUi;
// A3: the halt-availability gate that replaces setThreadBlocked while 3a is in and 3b is not. TRANSITIONAL: B4
// deletes all three (and the lines that test them) once nothing is switched off; G1a's bar greps for them.
void setHaltAvailable(bool on); // false: Break / Step / breakpoints greyed and attach() refuses; windows
// still live; turning it off detaches. Does not close the tools
bool haltAvailable() const;
std::string haltDisabledReason() const; // "" when available, else "Break, Step and breakpoints are not available
// while Settings > Interface > Emulation thread is on (this version)"
bool toolAvailable(const DebugTool& t) const; // enabled() && t.toggle && (!t.needsHalt || (haltAvailable() && halted()))
// (after B4: ... || halted())
// ---- marshalled actions (each uses callCore / postCore; the bps_ list stays the UI-side source of truth) ----
bool attach(); // false unless enabled() && haltAvailable(); callCore: core().attach() then
// breakSet for a by-value copy of every enabled bps_ entry
void detach(); // halted: the DETACH verdict; else postCore core().detach()
void breakNow(int target = VJ_DBG_TARGET_M68K); // attach(), then postCore haltNext; sets userHaltPending_ (B2)
void resume(); void step(); void detachAndResume(); // B1: core().verdict.store + notifyVerdict, only while halted() and
// core().haltSerial() == haltSerial() (a verdict for a dead halt is dropped)
bool read(unsigned addr, std::vector<uint8_t>& out, unsigned n) const; // callCore peek (one-shot readers only; the scanner's
// 2 MB scan pass is one of them)
int poke(unsigned addr, const uint8_t* data, unsigned n); // callCore poke; bumps the serial
bool getRegsNow(int target, vj_debug_regs& out) const; // callCore (edits / step-over only)
bool setReg(int target, int reg, unsigned v); // callCore
bool buildCryTable(std::vector<uint32_t>& out) const; // callCore, once per core per window
void opSnapshotArm(); void blitLogEnable(bool on); int texReplaceReload(); // callCore
std::string disabledReason() const; // the "Not available while ... Emulation thread" line is gone after A9
};
Sequencing of the old members: iface() (now core().iface()), setThreadBlocked() / threadBlocked() and the inline halt machine (halted_, queued_, pump, runUiFrame, abortHalt, onHalt, onResume, resumeQueued) stay on the session through A3-A8 (the windows migrate one at a time and debug_e2e.cpp uses dbg.onHalt); A9 removes iface() / setThreadBlocked(); B1 moves the halt machine to DebugCore (its onHalt / onResume fire on the emulation thread) and rewires debug_e2e.cpp and cpu_ui.cpp's hook to core().onHalt; B2 adds onHaltUi / onResumeUi and moves cpu_ui.cpp's hook there. The "same core" token the windows compare is views().epoch() (bumped by onCoreIface), never an iface pointer; ifaceGen() is for syncCore() only. Periodic readers never call read()/getRegsNow(); they views().set() a request, call wakeViews() once per draw, and views().read() the buffer, comparing buf.serial with refreshSerial() and buf.req with what they asked for.
App hooks (A9, B2)
// app.h
void fillDebugViews(); // emulation thread (inline: UI): debug.fillViews(). No gate: not debug.enabled(), not
// hardcore (Neuro's watches ride it); the 10 Hz cadence is the serial's (DebugSession::tick)
void haltWait(); // emulation thread, threaded: emu.serveDebugLane(DebugCore::kPumpWait); then
// fillDebugViews() if debug.views().pending(debug.refreshSerial())
std::atomic<bool> quitting_; // what DebugCore::abortHalt reads (B2), with g_signalQuit (B5); `quit` stays the UI's flag
bool quitRequested() const; // quit || g_signalQuit (B5): every run loop's exit test
std::function<bool()> haltGiveUp() const; // B3: [this]{ return debug.core().halt().halted; }
std::function<bool()> detachForCall(); // B3: threaded && debug.halted(): detachAndResume() now, then a predicate that
// gives up only on a NEW halt (haltSerial moved); else haltGiveUp()
void syncGdbHost(); // B4, UI thread from syncDebug(): once per load, when virtualjaguar_gdb_stub is enabled
// and debug.enabled(), debug.attach() and log "[debug] gdb host attached (silent)"
std::atomic<bool> gdbLockedByHardcore_; // B4: wireHost's optionLock forces virtualjaguar_gdb_stub=disabled while set
// app.h (file scope) / main.cpp (B5)
extern std::atomic<bool> g_signalQuit; // SIGINT / SIGTERM handlers store true
Call sites: fillDebugViews() at the end of App::emuRunOneFrame() (after host.publishSnapshot()) and once after the frame loop in App::stepEmulation() (not per frame: fast-forward runs four); haltedFrame() needs none because it runs syncDebug(), whose wakeViews() fills inline. debug.core().frameEnded() right after host.runFrame() in both. syncDebug() runs, in order, debug.syncCore(), debug.syncHalt(), the three existing lines, syncGdbHost(), debug.wakeViews(). DebugCore::waitVerdict = [this]{ haltWait(); } is set in startEmuThread() on success and cleared in its failure branch. limitFeaturesForEmuThread() shrinks in A9 to debug.setHaltAvailable(false) + the gdb option lock and is deleted in B4 (host.announceDebug stays true from A9 on).
app/src/input_snapshot.h (C1; remap.h provides ControlSet, CoreInput)
struct AgentStep {
ControlSet held;
unsigned frames = 1; // 1..600
};
struct AgentProgram {
uint64_t gen = 0; // 0 = no program; the UI bumps it on every publish (takeAgent is true only for a newer gen)
uint64_t lineage = 0; // bumped only by InputQueue::clear() (release_all, link drop, hardcore, input block, game
// change, Neuro off): a new lineage restarts the replica's count
uint64_t firstFrame = 0; // frame index, inside the lineage, of steps[0]'s first frame (the UI trims played frames
// and raises it), so a new gen of the same lineage neither replays nor drops frames
bool enabled = false; // Agent::allowed() && actions_.blocked().empty()
ControlSet hold; // added to every frame
std::vector<AgentStep> steps; // run in order after the frames already played
};
struct AgentProgress {
uint64_t gen = 0; // the program this reports on
uint64_t lineage = 0;
unsigned played = 0; // frames consumed from the steps, ABSOLUTE inside the lineage (not counting the hold)
bool done = false; // steps exhausted
ControlSet pressed; // what the last frame pressed (hold | step)
};
class InputSnapshot {
public:
// ...publish / take / takenSince unchanged (their mutex is not touched: the agent channel has its own)...
void publishAgent(const AgentProgram& p); // UI thread; the whole program, newest wins
bool takeAgent(AgentProgram& out); // emulation thread: true (and `out` filled) only when gen changed
void reportAgent(const AgentProgress& p); // emulation thread, after each frame()
AgentProgress agentProgress() const; // UI thread
};
app/src/neuro/neuro_actions.h (C1)
// The emulation-thread replica of InputQueue (pure; no sockets, no core).
class AgentPlayer {
public:
void load(const AgentProgram& p); // a new lineage restarts played at 0; the same lineage keeps it and skips
// played - firstFrame frames into the new steps (clamped up to firstFrame)
ControlSet frame(); // one emulated frame: hold | current step; advances; empty and NOT advancing when !enabled
AgentProgress progress() const;
uint64_t gen() const;
};
// The one function the app's input poll and the tests call: takeAgent + load on a new gen, frame(), reportAgent.
ControlSet pollAgent(InputSnapshot& s, AgentPlayer& p);
// InputQueue (UI side) gains:
uint64_t version() const; // bumped by every successful enqueue, every setHold that changes the hold, every clear
uint64_t lineage() const; uint64_t firstFrame() const;
void trim(unsigned frames); // drops the first `frames` frames of the steps (clamped); raises firstFrame
AgentProgram program(uint64_t gen, bool enabled) const; // its steps + hold + lineage + firstFrame as a program
Agent (neuro_agent.h): frame() is removed. void publishTo(InputSnapshot&) (a no-op when version(), lineage() and the enabled flag are unchanged) and void absorb(const AgentProgress&) (trims the queue by matching-lineage progress; pressedNow() = that progress's pressed, empty when not allowed) are called from neuroTick() (absorb before tick(), publish after it, unconditionally, so a clear made while !allowed() still reaches the emulation thread) and neuroShutdown(). uint64_t framesPlayed() const sums the absorbed progress. InputManager::agentControls goes; void InputManager::addAgentControls(ControlSet c) (if (c.any()) core_.addControls(0, c);) keeps the inline hashes unmoved. GameState::ram / ramSize are replaced by std::vector<uint8_t> watchBytes; uint32_t watchBase = 0; (the bytes of one Memory request covering the profile's watches, watchBase = its first address; empty = unknown, the state text says "?"); watchValue(const Watch&, const uint8_t* bytes, size_t size, uint32_t base) is the 4-argument overload (the 3-argument form is base = 0). bool Agent::watchRange(uint32_t& base, unsigned& len) const returns the covering range (false without watches); constexpr unsigned kMaxWatchSpan = 65536 (== DebugViews::kMaxMemoryBytes, static_asserted): a wider span is served by a Kind::Dram request instead of a clamp. void neuroHeadlessReport(App&) (neuro_app.cpp) prints [neuro] headless: N frames of input played and [neuro] state: <stateText with newlines as "|"> at the end of a headless --neuro run, inline and threaded.
app/src/build_channel.h (D1)
#ifndef VJ_BUILD_CHANNEL
#define VJ_BUILD_CHANNEL "dev"
#endif
#ifndef VJ_EMU_THREAD_DEFAULT
#define VJ_EMU_THREAD_DEFAULT 0
#endif
namespace vj {
constexpr const char* kBuildChannel = VJ_BUILD_CHANNEL; // "release" | "nightly" | "dev"
constexpr bool kEmuThreadDefault = VJ_EMU_THREAD_DEFAULT != 0; // nightly: true
constexpr bool emuThreadDefaultFor(std::string_view channel); // channel == "nightly"
constexpr bool emuThreadEffective(int pref, bool channelDefault); // pref < 0 ? channelDefault : pref == 1
}
The header ends with static_assert(kEmuThreadDefault == emuThreadDefaultFor(kBuildChannel), "VJ_EMU_THREAD_DEFAULT disagrees with VJ_BUILD_CHANNEL (app/CMakeLists.txt)"). App::emuThreadWanted() (app.cpp:282) becomes return emuThreadEffective(emuThreadPref, kEmuThreadDefault);. CMake (app/CMakeLists.txt): set(VJ_BUILD_CHANNEL "dev" CACHE STRING "release | nightly | dev: the build channel (nightlies default the emulation thread on)"), set_property(CACHE VJ_BUILD_CHANNEL PROPERTY STRINGS release nightly dev), a FATAL_ERROR on any other value, and an INTERFACE target vj_build_channel carrying VJ_BUILD_CHANNEL="${VJ_BUILD_CHANNEL}" and VJ_EMU_THREAD_DEFAULT=<1 for nightly, else 0>, linked into virtualjaguar and the default test. Start-up prints two lines on every run, headless included: [emu] channel=<c>, emulation thread default <on|off>, this run <on|off> and [emu] ui.emu_thread <unset|on|off> -> wanted <on|off> (the stored choice and emuThreadWanted(); headless is always inline, so the second line is what pins the rule). Workflow: app-package.yml's meta job emits channel (nightly -> nightly, v* -> release, else dev); the three cmake -S app lines add -DVJ_BUILD_CHANNEL="$CHANNEL" from a job env var; the flatpak manifest gains - -DVJ_BUILD_CHANNEL=release and the flatpak job seds it to nightly when needs.meta.outputs.label == 'nightly'. Test-support flags for the Studio gate (G1b, not part of the frozen interfaces): --show-window tools opens every Tools window and logs [app] tool <id> open=<0|1>; --perf-halt does F9's break at UI iteration 120, re-opens the tools at 240 (logging open=... (halted) and [perf] halted=<0|1>) and resumes at 420.
File Structure
| File | Status | Sub-phase | Responsibility |
|---|---|---|---|
app/src/emu_commands.h |
modify | 3a | Lane, the debug deque, per-lane drain, pending |
app/src/emu_thread.h, app/src/emu_thread.cpp |
modify | 3a, 3b | debugCall/debugPost, the lane drain in loop(), serveDebugLane; the giveUp predicate (3b) |
app/src/debug/debug_views.h, app/src/debug/debug_views.cpp |
new | 3a | request table, double buffers, fill, epoch |
app/src/debug/debug_core.h, app/src/debug/debug_core.cpp |
new | 3a, 3b | the emulation-thread half: iface wrappers; the halt pump, verdict, HaltState (3b) |
app/src/debug/debug_session.h, app/src/debug/debug_session.cpp |
modify | 3a, 3b | UI mirror, coreCall/corePost + callCore/postCore, views(), fillViews/wakeViews/onCoreIface/syncCore, setHaltAvailable/haltDisabledReason; syncHalt, haltByGdb, onHaltUi (3b) |
app/src/debug/core_job.h |
new | 3a | CoreJob<T>: work posted to the emulation thread whose result the UI takes later (CPU navigation walks, the OP render and the fallback walk) |
app/src/debug/blitter_feed.h, app/src/debug/blitter_feed.cpp |
new | 3a | blit::LogFeed: the blitter window's requests, absorb and the log enable/clear calls |
app/tests/debug_ui_fixture.h, app/cmake/CheckNoDirectIface.cmake |
new | 3a | CountingIface / UiFixture (every window smoke counts core calls and off-core calls); the static "no direct iface" guard (debug_no_direct_iface) |
app/tests/debug_core_job_test.cpp, debug_cpu_ui_smoke.cpp, debug_blitter_feed_test.cpp, debug_blitter_ui_smoke.cpp, debug_scope_gate_test.cpp |
new | 3a | per-window unit and smoke tests |
app/src/main.cpp |
modify | 3b, 3d | SIGINT/SIGTERM handlers (g_signalQuit, 3b); --show-window tools, --perf-halt (G1b) |
app/tests/neuro_core_e2e.cpp |
new | 3c | the real core's joypad matrix with a program, inline and threaded, lock-step and free-running |
app/tests/no_threaded_limits.sh, app/tests/threaded_branch_allowlist.txt |
new | G1a | the mechanical bar: no gate, no "off while threaded" text, no old limit test, no unlisted threaded() branch, tsan.supp unchanged |
app/tests/emu_thread_gate_check.sh, app/tests/emu_thread_gate_gdb.py, app/tests/neuro_gate_server.py |
new | G1b | the Studio gate: tools + F9 halt + Neuro, gdb halt with the tools open, plain; a stdlib WebSocket Neuro server; a gdb probe |
app/packaging/README.md |
modify | 3d | the build channel |
app/src/debug/memory_ui.cpp, memory_view.cpp/.h |
modify | 3a | window range + cry table through views / coreCall |
app/src/debug/clut_ui.cpp |
modify | 3a | two Memory requests |
app/src/debug/cpu_ui.cpp, cpu_view.cpp/.h |
modify | 3a | Regs + Disasm requests; edits, temp breakpoints, step-over through the session |
app/src/debug/op_ui.cpp, op_view.cpp/.h, op_render.cpp/.h |
modify | 3a | OpSnapshot request; arm / render / patch through coreCall; "step one frame" = emu.advance(1) |
app/src/debug/blitter_ui.cpp, blitter_log.cpp/.h |
modify | 3a | BlitLog + regs requests; enable/clear through coreCall |
app/src/debug/texture_ui.cpp, texture_pack.cpp/.h |
modify | 3a | reload through coreCall; replaceTile takes a reload callable instead of the iface |
app/src/debug/scanner_ui.cpp, scanner_actions.cpp |
modify | 3a | Dram request replaces memoryRegion; pokes through the session |
app/src/debug/scope_ui.cpp |
modify | 3a | gate lifted only |
app/src/debug/debug_ui.cpp |
modify | 3a, 3b | Tools menu uses toolAvailable; the halt bar's "applies after Continue" line and "by gdb" (3b) |
app/src/app.h, app/src/app.cpp |
modify | all | fillDebugViews, limits narrowed (3a); haltWait, quitting_, syncHalt, give-up at call sites, detach-first, shutdown detach, gdb unlock + silent attach, haltedFrame inline-only (3b); inputPoll with AgentPlayer, publishInput agent program, watches (3c); emuThreadWanted, the start-up log line (3d) |
app/src/core_host.cpp |
modify | 3b | refusal texts only ("resume it first" also names F9) |
app/src/achievements.cpp |
modify | 3b | hardcore locks virtualjaguar_gdb_stub to disabled (Decision 2) |
app/src/ui.cpp |
modify | 3b, 3d | options tab "applies after Continue" while halted; the Interface hint and default note |
app/src/desktop.cpp |
modify | 3b | F9/F10 menu commands through the mirror |
app/src/input_snapshot.h |
modify | 3c | AgentStep, AgentProgram, AgentProgress, the four agent methods |
app/src/input.h, app/src/input.cpp |
modify | 3c | agentControls removed |
app/src/neuro/neuro_actions.h/.cpp, neuro_agent.h/.cpp, neuro_app.cpp |
modify | 3c | AgentPlayer, program(), publishTo/absorb, watch range, Settings hint |
app/src/build_channel.h |
new | 3d | the channel and the default rule |
app/CMakeLists.txt |
modify | 3d | VJ_BUILD_CHANNEL, the defines |
.github/workflows/app-package.yml, app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml, .github/workflows/app.yml |
modify | 3d | channel plumbing; one nightly-channel CI configure |
app/cmake/VJEmuThreadTests.cmake, VJDebugTests.cmake, VJNeuroTests.cmake |
modify | all | new units, threaded twins, the rewritten limit tests |
app/tests/emu_commands_test.cpp, emu_thread_test.cpp, debug_session_test.cpp, debug_mock.h |
modify | 3a, 3b | lanes, debugCall, give-up; DebugCore with the mock, bounded pump |
app/tests/debug_views_test.cpp, debug_views_threaded.cpp, debug_e2e_threaded.cpp, gdb_threaded_halt.py, emu_thread_default_test.cpp |
new | 3a, 3b, 3d | unit and twin tests named in Review Focus |
app/tests/neuro_test.cpp |
modify | 3c | AgentPlayer, program/progress, the loopback twin |
app/README.md, docs/agent/testing.md, docs/app-debug-tools.md, docs/neuro.md, docs/app-emulation-thread-design.md |
modify | all | each sub-phase's docs task |
Task numbering
- 3a (debug views): A1 lanes +
debugCall; A2DebugViews; A3DebugCorewrappers + session marshalling (inline unchanged,debug_session_unitgreen); A4 Memory viewer + Palette; A5 CPU debugger; A6 Object list; A7 Blitter log + Textures; A8 Scanner + Scope; A9 App wiring, limits narrowed,debug_views_threaded, docs, PR. - 3b (halt + gdb): B1
HaltState, verdict, boundedpump()(mock tests); B2 App threaded halt wiring (waitVerdict,syncHalt,quitting_, shutdown detach); B3call()give-up + call-site policy ("applies after Continue", detach-first, refusals); B4 gdb unlock, silent attach, hardcore lock; B5debug_e2e_threaded,gdb_threaded_halt, limit tests rewritten; B6 docs, Studio checks, PR. - 3c (Neuro): C1
AgentProgram/AgentPlayer+InputSnapshotmethods (unit); C2Agentpublishes/absorbs,agentControlsremoved,inputPoll; C3 watches throughDebugViews; C4 loopback twin,neuro_on_threaded, docs, PR. - 3d (flag): D1 CMake +
build_channel.h+emuThreadWanted+ unit test; D2 Settings/About/log line + smoke; D3 workflows, flatpak sed, nightly CI configure, docs, PR. - G1 (gate, on develop after 3a, 3b and 3c merged, before 3d's PR opens; its two code commits are the first on the 3d branch): G1a
no_threaded_limits, the mechanical bar kept in CI; G1b the Studio drivers (--show-window tools,--perf-halt) andemu_thread_gate_check.shwith a stdlib Neuro server and a gdb probe; G1c full suite, TSan, the windowed gate (Release and TSan builds), evenness A/B/B/A with the Memory window open; G1d a whole-branch architecture review by a model that did not write 3a-3c. Results into 3d's PR body.
Each task's steps follow the Phase 2 style: failing test, run it, implement the named signature, run it, commit.
Sub-phase 3a: debug views on the emulation thread
Branch feat/967-phase3a-debug-views off libretro/develop, worktree ~/Workspace/Provenance/vj-wt/emu-p3a (create it with git -C ~/Workspace/Provenance/virtualjaguar-libretro fetch libretro develop && git -C ~/Workspace/Provenance/virtualjaguar-libretro worktree add ~/Workspace/Provenance/vj-wt/emu-p3a -b feat/967-phase3a-debug-views libretro/develop; all commands below run from its root). After this PR, with Settings > Interface > Emulation thread on, every Tools window works (Memory, Palette, Cheat scanner, CPU debugger, Object list, Blitter log, Textures, Audio scope) and --show-window <tool> opens it threaded. It leaves off: the halt (Break, Step, breakpoints are greyed with a reason; announceDebug is back on so the core hands over its interface, but attach() refuses while haltAvailable() is false, so the core can never halt for the app), the GDB stub (option still locked to disabled), Neuro, and the nightly default. The setting stays Off by default.
Every task ends with a green build and the existing suite passing in inline mode; a task never leaves a window half-migrated. A1-A3 are plumbing and change no behaviour; A4-A8 (separate section) migrate one window each; A9 wires the App and lifts the thread gate.
Build and test commands used throughout (T = a target, R = a ctest regex):
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4 --target T && ctest --test-dir build -R R --output-on-failure
Check uptime first; do not start a build while the load is above 10.
Task A1: Lane::Debug in EmuCommands, and EmuThread::debugCall / debugPost
Files:
- Modify:
app/src/emu_commands.h(whole class, lines 23-128: second deque, per-lanedrain,pending,closeAndDrain,waitUntilpredicates at :61 and :70) - Modify:
app/src/emu_thread.h(call:84-90 and its body :144-172 become one privatesubmit; newdebugCall,debugPost) - Modify:
app/src/emu_thread.cpp(post:83-91 gets a twin;loop():154 drains both lanes) - Test:
app/tests/emu_commands_test.cpp,app/tests/emu_thread_test.cpp,app/cmake/VJEmuThreadTests.cmake(header comment lines 6 and 8 only)
Interfaces:
-
Consumes: nothing new.
-
Produces (verbatim from the Interfaces section):
EmuCommands::Lane { Frame, Debug },post(Fn&&, Lane = Frame),drain(const ErrorFn&, Lane = Frame),pending(Lane) const,closeAndDrainover both lanes;EmuThread::debugCall(F&&) -> decltype(fn()),EmuThread::debugPost(std::function<void()>). Not in this task:serveDebugLaneand thegiveUpargument (B1, B3). -
Step 1: Write the failing tests. In
app/tests/emu_commands_test.cpp, beforemain, add:
using Lane = EmuCommands::Lane;
void testLanes() {
EmuCommands q;
std::string ran;
q.post([&ran] { ran += 'F'; });
q.post([&ran] { ran += 'D'; }, Lane::Debug);
CHECK(q.pending(Lane::Frame) && q.pending(Lane::Debug), "each lane holds its command");
CHECK(q.drain(nullptr, Lane::Debug) == 1 && ran == "D" && q.pending(Lane::Frame),
"drain(Lane::Debug) leaves the frame lane untouched and vice versa (debug first)");
q.post([&ran] { ran += 'd'; }, Lane::Debug);
CHECK(q.drain(nullptr, Lane::Frame) == 1 && ran == "DF" && q.pending(Lane::Debug),
"drain(Lane::Frame) leaves the debug lane untouched (frame first)");
CHECK(q.drain(nullptr) == 0, "the default lane is the frame lane: nothing left there");
CHECK(q.drain(nullptr, Lane::Debug) == 1 && ran == "DFd" && !q.pending(Lane::Debug), "the debug lane is FIFO");
}
void testDebugLaneWakesAndCloses() {
EmuCommands q;
Clock::time_point t0 = Clock::now();
std::thread poster([&q] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
q.post([] {}, Lane::Debug);
});
q.waitUntil(t0 + std::chrono::seconds(5));
CHECK(since(t0) < 1.0, "a debug-lane post wakes the wait early (%.3f s)", since(t0));
poster.join();
q.drain(nullptr, Lane::Debug);
std::string ran;
q.post([&ran] { ran += 'F'; });
q.post([&q, &ran] { // a debug command that queues another one while the queue closes: it still runs
ran += 'D';
q.post([&ran] { ran += 'E'; }, Lane::Debug);
}, Lane::Debug);
CHECK(q.closeAndDrain(nullptr) == 3 && ran == "FDE", "closeAndDrain runs both lanes, frame then debug each round (%s)", ran.c_str());
CHECK(q.closed() && !q.post([] {}, Lane::Debug) && !q.post([] {}), "then both lanes refuse commands");
q.reopen();
CHECK(q.post([] {}, Lane::Debug) && q.pending(Lane::Debug), "reopen() takes debug commands again");
}
Call testLanes(); testDebugLaneWakesAndCloses(); from main after testProducers();.
In app/tests/emu_thread_test.cpp, before main, add (sleepMs, since, FakeClient already exist; holdAt holds runFrame after it counted its frame):
// #967 phase 3: the debug lane. Inline it runs at once; threaded it runs on the emulation thread, wakes a parked
// (paused) loop, waits for a frame in progress and runs at the boundary BEFORE the next frame starts, and runs the
// wait hook like call().
void testDebugLane() {
{
EmuThread e;
FakeClient c;
CHECK(e.start(EmuThread::Mode::Inline, c), "inline start");
int x = 0;
e.debugPost([&x] { x = 1; });
CHECK(x == 1, "inline debugPost runs at once");
CHECK(e.debugCall([&x] { return x + 1; }) == 2, "inline debugCall runs at once");
}
EmuThread e;
FakeClient c;
e.setPaused(true); // no frames: only a command can wake the loop
CHECK(e.start(EmuThread::Mode::Threaded, c), "threaded start");
CHECK(e.debugCall([&e] { return e.onEmuThread(); }), "a debugCall runs on the emulation thread");
CHECK(e.debugCall([&e] { return e.debugCall([] { return 7; }); }) == 7,
"a debugCall made on the emulation thread runs inline");
std::atomic<bool> ran{false};
const Clock::time_point t0 = Clock::now();
e.debugPost([&ran] { ran = true; });
for (int i = 0; i < 400 && !ran.load(); i++) sleepMs(1);
CHECK(ran.load() && since(t0) < 0.2, "a debugPost runs on a paused thread well inside its 250 ms idle wait (%.3f s)", since(t0));
CHECK(c.frames == 0, "...without running a frame");
std::atomic<bool> late{false};
e.debugPost([&late] { late = true; });
CHECK(e.stop() && late.load(), "stop() drains the debug lane too");
}
void testDebugCallWaitsForTheFrame() {
EmuThread e;
FakeClient c;
c.holdAt = 1; // the first frame counts itself, then holds
CHECK(e.start(EmuThread::Mode::Threaded, c), "start");
for (int i = 0; i < 1000 && c.frames.load() < 1; i++) sleepMs(1);
std::atomic<int> framesWhenRun{-1};
std::atomic<bool> returned{false};
std::thread caller([&] {
e.debugCall([&] { framesWhenRun = c.frames.load(); });
returned = true;
});
sleepMs(80);
CHECK(!returned.load() && framesWhenRun.load() == -1, "a debugCall waits while a frame is in progress");
c.holdAt = -1;
caller.join();
CHECK(framesWhenRun.load() == 1, "it runs at the frame boundary, before the next frame starts (frames %d)",
framesWhenRun.load());
CHECK(e.stop(), "stop");
}
void testDebugCallWaitHook() {
EmuThread e;
FakeClient c;
std::atomic<int> calls{0};
e.setWaitHook([&](double, unsigned) { calls++; });
c.holdAt = 1;
CHECK(e.start(EmuThread::Mode::Threaded, c), "start");
for (int i = 0; i < 1000 && c.frames.load() < 1; i++) sleepMs(1);
std::thread release([&c] {
sleepMs(450);
c.holdAt = -1;
});
e.debugCall([] { return 0; });
release.join();
CHECK(calls.load() >= 3, "a debugCall behind a slow frame runs the wait hook like call() (%d)", calls.load());
CHECK(e.stop(), "stop");
}
Call all three from main after testWaitHook();. In testJoinTimeout, after the existing CHECK(!e->callFor(... [&ran] ... line, add:
CHECK(e->debugCall([&ran] {
ran = true;
return 1;
}) == 0 && !ran, "after abandoning, debugCall() runs nothing and returns a default value");
- Step 2: Run to see them fail.
Run: cmake --build build -j4 --target vj_emu_commands_test vj_emu_thread_test
Expected: compile errors, no matching member function for call to 'post' (Lane argument) and no member named 'debugCall' in 'vj::EmuThread'.
-
Step 3: Implement.
app/src/emu_commands.h: addenum class Lane : uint8_t { Frame, Debug };(<cstdint>),std::deque<Fn> dbg_;besideq_.post(Fn&&, Lane = Lane::Frame)pushes into that lane's deque and setswake_as today.drain(const ErrorFn&, Lane = Lane::Frame)swaps out that lane's deque only. BothwaitUntilpredicates (the generic one and the MinGWpendinglambda) becomewake_ || !q_.empty() || !dbg_.empty().closeAndDrain: each round swaps both deques out under the lock; if both were empty setclosed_and return; else run the frame batch then the debug batch, add both sizes ton.bool pending(Lane) constlocks and tests that deque. Header comment: one sentence on the two lanes.app/src/emu_thread.h: rename the body ofcall()intotemplate <class F> auto submit(F&& fn, EmuCommands::Lane lane) -> decltype(fn())(private; the code is unchanged exceptcmds_.post([task] { (*task)(); }, lane));call(F&& fn)returnssubmit(std::forward<F>(fn), EmuCommands::Lane::Frame)anddebugCall(F&& fn)returnssubmit(..., EmuCommands::Lane::Debug).callForis untouched. Declarevoid debugPost(std::function<void()> fn);with the frozen comment (safe insideretro_run(); the wait hook contract issetWaitHook's). Document atdebugCall: captures must be by value when the thread may be abandoned (the same rule ascall), and there is no ordering between the two lanes beyond "frame lane first at each boundary".app/src/emu_thread.cpp:debugPostispost()'s body withcmds_.post(std::move(fn), EmuCommands::Lane::Debug). Inloop()replace line 154 withsize_t ran = cmds_.drain(onError, EmuCommands::Lane::Frame); ran += cmds_.drain(onError, EmuCommands::Lane::Debug); if (ran > 0) client_->afterCommands();.closeAndDrainalready covers both lanes.app/cmake/VJEmuThreadTests.cmake: header lines 6 and 8 mention the lanes anddebugCall(comment only).
-
Step 4: Run to see them pass.
Run: cmake --build build -j4 --target vj_emu_commands_test vj_emu_thread_test && ctest --test-dir build -R "emu_commands_unit|emu_thread_unit" --output-on-failure
Expected: both PASS; emu_commands_test: OK, emu_thread_test: OK. Also run ctest --test-dir build -L emu_thread -R "core_host_thread|emu_thread_e2e|smoke_yarc_threaded" --output-on-failure: PASS (the loop change touches every threaded run).
- Step 5: Commit
git add app/src/emu_commands.h app/src/emu_thread.h app/src/emu_thread.cpp app/tests/emu_commands_test.cpp app/tests/emu_thread_test.cpp app/cmake/VJEmuThreadTests.cmake
git commit -F - <<'EOF'
feat(app): a debug lane in the emulation thread's command queue (#967)
EmuCommands gets Lane::Debug beside the frame lane; the loop drains both at
each frame boundary. EmuThread::debugCall / debugPost are safe inside
retro_run() once the halt pump serves the lane (phase 3b).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task A2: DebugViews, the request table and double buffers
Files:
- Create:
app/src/debug/debug_views.h,app/src/debug/debug_views.cpp - Create:
app/tests/debug_views_test.cpp - Modify:
app/CMakeLists.txt(addsrc/debug/debug_views.cppaftersrc/debug/debug_session.cppat :340),app/cmake/VJDebugTests.cmake(new target at the end)
Interfaces:
- Consumes:
vj_debug_iface(src/debug/vj_debug_iface.h),DebugSession::kDramSize(debug_session.h:51; included from the.cpponly, so the headers do not cycle). - Produces:
DisasmLine,ViewRequest,ViewBuffer,DebugViewsexactly as in the Interfaces section.
Semantics the tests pin (all in debug_views.cpp):
- State. A
std::map<Id, Slot>under one mutex,Slot { ViewRequest req; uint64_t rev; ViewBuffer front; }, ids from 1,std::atomic<uint64_t> epoch_{1}. set(). ClampslentokMaxMemoryBytesandrowstokMaxDisasmRows. A request equal to the stored one (every field) changes nothing. A different one stores it, bumps the slot's privaterevand clearsfront(serial = 0).fill(iface, serial). (1) Return 0 if!iface. (2) Under the lock copy(id, req, rev)of every slot whosefront.serial != serial || front.epoch != epoch(). (3) Per copied request, with no lock held, readepoch0 = epoch()first, then fill a localViewBuffer(table below), settingserial,epoch = epoch0,frame = iface->frame_number(). (4) Under the lock, install it only if the slot still exists and itsrevis unchanged; otherwise drop it. (5) Return the number installed.read(). False when the id is unknown,front.serial == 0, orfront.epoch != epoch().pending(serial). True if any slot would be copied by step (2).
| Kind | Fill | ok |
|---|---|---|
| Memory | iface->peek(addr, len) into bytes |
peek returned len (else bytes empty) |
| Dram | peek(0, DebugSession::kDramSize) |
same |
| Regs | get_regs(target) into regs |
it returned 1 |
| Disasm | with a DisasmFn installed (setDisasm, A5): lines = fn(iface, target, addr, rows); without one, RISC targets only: rows x disasm_risc(target, a, ...), a += len; a failed row is {a, 0, "", ""} and a += 2 |
with a fn: true; without: true for GPU/DSP with the function present, an M68K request: false, no lines |
| OpSnapshot | n = op_snapshot_get(buf, VJ_DBG_OP_MAX_OBJECTS, &halfline); opCount = n; objs = first n |
true when the function is present (n == -1 is a valid answer) |
| BlitLog | as blit::LogTracker::sync (blitter_log.cpp:195-206): head = blit_log_head(); blitHead = head; head < since: no entries, blitLost = 0; else oldest = head > 256 ? head - 256 : 0, start = max(since, oldest), blitLost = start - since plus one per blit_log_get failure in [start, head), blits = the successes |
true when both functions are present |
Any kind with a null function pointer in the iface fills ok = false.
- Step 1: Write the failing tests. Create
app/tests/debug_views_test.cpp:
// DebugViews (src/debug/debug_views.h): the request table and per-request double buffers the emulation thread fills
// and the tool windows read (#967 phase 3a). Mock core, no window.
//
// vj_debug_views_test
#include "debug_mock.h"
#include "debug_session.h" // DebugSession::kDramSize
#include "debug_views.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <functional>
#include <thread>
using namespace vj;
using Id = DebugViews::Id;
static int g_fail = 0;
#define CHECK(c, ...) \
do { \
if (!(c)) { \
std::printf("FAIL %s:%d: ", __FILE__, __LINE__); \
std::printf(__VA_ARGS__); \
std::printf("\n"); \
g_fail++; \
} \
} while (0)
static ViewRequest mem(unsigned addr, unsigned len) {
ViewRequest r;
r.kind = ViewRequest::Kind::Memory;
r.addr = addr;
r.len = len;
return r;
}
// An iface whose peek() runs a hook: lets a test act "during the fill", with no lock held.
struct HookIface {
static inline std::function<void()> onPeek;
static int peek(unsigned, unsigned char* d, unsigned n) {
if (onPeek) onPeek();
for (unsigned i = 0; i < n; i++) d[i] = 0xAB;
return (int)n;
}
static unsigned frame() { return 7; }
static vj_debug_iface make() {
vj_debug_iface i{};
i.size = sizeof i;
i.version = VJ_DEBUG_IFACE_VERSION;
i.peek = &HookIface::peek;
i.frame_number = &HookIface::frame;
return i;
}
};
static void testSetClampsAndIds() {
DebugViews v;
const Id a = v.set(DebugViews::kNoId, mem(0x1000, 1u << 20)), b = v.set(DebugViews::kNoId, mem(0x2000, 16));
CHECK(a != DebugViews::kNoId && b != DebugViews::kNoId && a != b && v.count() == 2, "two requests, two ids");
CHECK(v.set(a, mem(0x1000, 1u << 20)) == a && v.count() == 2, "an existing id is replaced, not added");
ViewRequest d;
d.kind = ViewRequest::Kind::Disasm;
d.target = VJ_DBG_TARGET_GPU;
d.addr = 0xF03000;
d.rows = 9999;
const Id c = v.set(DebugViews::kNoId, d);
MockDebugIface m;
v.fill(m.iface(), 1);
ViewBuffer out;
CHECK(v.read(a, out) && out.bytes.size() == DebugViews::kMaxMemoryBytes, "len is clamped to 64 KiB (%zu)", out.bytes.size());
CHECK(v.read(c, out) && out.lines.size() == DebugViews::kMaxDisasmRows, "rows are clamped to 512 (%zu)", out.lines.size());
v.clear(b);
v.clear(12345); // unknown: no-op
CHECK(v.count() == 2 && !v.read(b, out), "clear() removes one request; an unknown id is a no-op");
}
static void testFillKinds() {
MockDebugIface m; // DRAM byte i = i & 0xFF, frame_number 1234, 4 OP objects at $1000, 5 blits
DebugViews v;
const Id mm = v.set(DebugViews::kNoId, mem(0x1000, 16));
ViewRequest dr;
dr.kind = ViewRequest::Kind::Dram;
const Id dram = v.set(DebugViews::kNoId, dr);
ViewRequest rg;
rg.kind = ViewRequest::Kind::Regs;
rg.target = VJ_DBG_TARGET_GPU;
const Id regs = v.set(DebugViews::kNoId, rg);
ViewRequest di;
di.kind = ViewRequest::Kind::Disasm;
di.target = VJ_DBG_TARGET_GPU;
di.addr = 0xF03006; // the mock says a 6-byte "movei" when addr % 8 == 6
di.rows = 3;
const Id dis = v.set(DebugViews::kNoId, di);
di.target = VJ_DBG_TARGET_M68K;
const Id dis68 = v.set(DebugViews::kNoId, di);
ViewRequest op;
op.kind = ViewRequest::Kind::OpSnapshot;
const Id ops = v.set(DebugViews::kNoId, op);
ViewRequest bl;
bl.kind = ViewRequest::Kind::BlitLog;
const Id blit = v.set(DebugViews::kNoId, bl);
CHECK(v.fill(m.iface(), 9) == 7, "all seven are filled");
ViewBuffer b;
CHECK(v.read(mm, b) && b.ok && b.serial == 9 && b.frame == 1234 && b.bytes.size() == 16 && b.bytes[0] == 0x00 &&
b.bytes[15] == 0x0F, "Memory: the bus bytes at the frame the fill ran");
CHECK(v.read(dram, b) && b.ok && b.bytes.size() == DebugSession::kDramSize && b.bytes[0x1234] == 0x34, "Dram: 2 MiB from 0");
CHECK(v.read(regs, b) && b.ok && b.regs.risc_pc == 0xF03000, "Regs: GPU pc");
CHECK(v.read(dis, b) && b.ok && b.lines.size() == 3 && b.lines[0].addr == 0xF03006 && b.lines[0].len == 6 &&
b.lines[1].addr == 0xF0300C, "Disasm: a 6-byte MOVEI advances by 6");
CHECK(v.read(dis68, b) && !b.ok && b.lines.empty(), "Disasm of the 68000 needs the injected DisasmFn (A5 installs it)");
v.setDisasm([](const vj_debug_iface*, int t, unsigned a, unsigned n) {
std::vector<DisasmLine> out;
for (unsigned i = 0; i < n; i++) out.push_back({a + 2 * i, 2, t == VJ_DBG_TARGET_M68K ? "nop" : "fake", "4E71"});
return out;
});
v.set(dis68, di); // unchanged request: the buffer stays; a new serial refills it through the fn
v.fill(m.iface(), 10);
CHECK(v.read(dis68, b) && b.ok && b.lines.size() == 3 && b.lines[0].text == "nop" && b.lines[0].bytes == "4E71",
"with a DisasmFn every target is served by it");
v.setDisasm(nullptr);
m.opReady = false;
v.fill(m.iface(), 11);
CHECK(v.read(ops, b) && b.ok && b.opCount == -1, "OpSnapshot: armed, not taken yet is -1");
m.opReady = true;
m.iface()->op_snapshot_arm();
v.fill(m.iface(), 12);
CHECK(v.read(ops, b) && b.opCount == 4 && b.objs.size() == 4 && b.objs[0].address == MockDebugIface::kOpListBase &&
b.halfline == 41, "OpSnapshot: four objects, halfline 41");
CHECK(v.read(blit, b) && b.ok && b.blitHead == 5 && b.blits.size() == 5 && b.blitLost == 0, "BlitLog since 0: the five launches");
m.setBlitRingForTest(4); // launch 0 has scrolled out
v.fill(m.iface(), 13);
CHECK(v.read(blit, b) && b.blits.size() == 4 && b.blits.front().seq == 1 && b.blitLost == 1, "BlitLog: lost launches are counted, never replayed");
bl.since = 9; // past the head: the log was switched off and on again
v.set(blit, bl);
v.fill(m.iface(), 13);
CHECK(v.read(blit, b) && b.blits.empty() && b.blitHead == 5 && b.blitLost == 0, "BlitLog: since > head copies nothing and reports the head");
}
static void testSerialAndPending() {
MockDebugIface m;
DebugViews v;
const Id a = v.set(DebugViews::kNoId, mem(0, 4));
CHECK(v.pending(1), "a new request is pending");
CHECK(v.fill(m.iface(), 1) == 1 && !v.pending(1), "filled at serial 1");
CHECK(v.fill(m.iface(), 1) == 0, "fill() runs once per serial");
CHECK(v.pending(2) && v.fill(m.iface(), 2) == 1, "a new serial refills");
ViewBuffer b;
CHECK(v.read(a, b) && b.serial == 2, "the buffer carries the serial it was filled under");
CHECK(v.fill(nullptr, 3) == 0 && v.pending(3), "a null iface fills nothing and stays pending");
}
static void testSetInvalidates() {
MockDebugIface m;
DebugViews v;
const Id a = v.set(DebugViews::kNoId, mem(0x10, 4));
v.fill(m.iface(), 1);
ViewBuffer b;
v.set(a, mem(0x10, 4));
CHECK(v.read(a, b) && !v.pending(1), "an unchanged set() keeps the filled buffer (windows re-state their request every frame)");
v.set(a, mem(0x20, 4));
CHECK(!v.read(a, b) && v.pending(1), "a changed set() clears the buffer and is pending at the SAME serial (a paused window that scrolls)");
v.fill(m.iface(), 1);
CHECK(v.read(a, b) && b.bytes[0] == 0x20, "and the refill carries the new range");
}
static void testFillHoldsNoLock() {
DebugViews v;
const vj_debug_iface hi = HookIface::make();
const Id a = v.set(DebugViews::kNoId, mem(0, 4));
HookIface::onPeek = [&] { v.set(a, mem(0x100, 4)); }; // would deadlock if the lock were held across the iface call
v.fill(&hi, 1);
HookIface::onPeek = nullptr;
ViewBuffer b;
CHECK(!v.read(a, b) && v.pending(1), "a set() made during the fill is not overwritten by the stale result");
v.fill(&hi, 1);
CHECK(v.read(a, b) && b.ok, "the next fill serves the new request");
}
static void testEpoch() {
MockDebugIface m;
DebugViews v;
const Id a = v.set(DebugViews::kNoId, mem(0, 4));
v.fill(m.iface(), 1);
ViewBuffer b;
CHECK(v.read(a, b), "readable after the fill");
const uint64_t e0 = v.epoch();
v.bumpEpoch();
CHECK(v.epoch() == e0 + 1 && !v.read(a, b), "a buffer filled before bumpEpoch() is not readable after it");
CHECK(v.pending(1) && v.fill(m.iface(), 1) == 1 && v.read(a, b) && b.epoch == v.epoch(),
"fill() at the same serial refills a stale-epoch buffer");
const vj_debug_iface hi = HookIface::make();
DebugViews w;
const Id c = w.set(DebugViews::kNoId, mem(0, 4));
HookIface::onPeek = [&] { w.bumpEpoch(); };
w.fill(&hi, 1);
HookIface::onPeek = nullptr;
CHECK(!w.read(c, b), "an epoch bump between the table copy and the swap leaves the buffer unreadable");
}
// set() and read() on one thread, fill() on another: every buffer matches the request of its own id (never torn).
static void testConcurrent() {
MockDebugIface m;
DebugViews v;
const Id a = v.set(DebugViews::kNoId, mem(0x100, 64)), b = v.set(DebugViews::kNoId, mem(0x400, 64));
const Id churn = v.set(DebugViews::kNoId, mem(0, 8));
std::atomic<bool> stop{false};
std::thread filler([&] {
for (uint64_t s = 1; !stop.load(); s++) v.fill(m.iface(), s);
});
std::thread churner([&] {
for (unsigned i = 0; !stop.load(); i++) v.set(churn, mem(0x1000 + (i & 7) * 16, 8 + (i & 3)));
});
int reads = 0, torn = 0;
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(300);
while (std::chrono::steady_clock::now() < end) {
ViewBuffer x;
if (v.read(a, x)) {
reads++;
if (x.bytes.size() != 64 || x.bytes[0] != 0x00 || x.bytes[63] != 0x3F) torn++; // DRAM i & 0xFF: $100.. -> 00..3F
}
if (v.read(b, x)) {
reads++;
if (x.bytes.size() != 64 || x.bytes[0] != 0x00 || x.bytes[63] != 0x3F) torn++;
}
}
stop = true;
filler.join();
churner.join();
CHECK(reads > 0 && torn == 0, "reads are never torn (%d reads, %d torn)", reads, torn);
}
int main() {
testSetClampsAndIds();
testFillKinds();
testSerialAndPending();
testSetInvalidates();
testFillHoldsNoLock();
testEpoch();
testConcurrent();
std::printf(g_fail ? "debug_views_test: %d FAILED\n" : "debug_views_test: OK\n", g_fail);
return g_fail ? 1 : 0;
}
(debug_session.h is included only for the constant DebugSession::kDramSize; at this task it does not yet include debug_views.h, so there is no cycle.)
Register in app/cmake/VJDebugTests.cmake (the test has the emu_thread label so the ThreadSanitizer job runs the concurrent case):
# debug_views_unit DebugViews (debug_views.*): the request table and double buffers the emulation thread fills
# and the windows read -- clamps, per-kind fills against the mock, serials, a changed request
# invalidating its buffer, the epoch across a content switch, and a concurrent set/fill/read.
add_executable(vj_debug_views_test tests/debug_views_test.cpp src/debug/debug_views.cpp)
target_include_directories(vj_debug_views_test PRIVATE src/debug tests "${VJ_ROOT}/src/debug")
target_link_libraries(vj_debug_views_test PRIVATE Threads::Threads)
if(MSVC)
target_compile_options(vj_debug_views_test PRIVATE /W3 /utf-8)
target_compile_definitions(vj_debug_views_test PRIVATE _CRT_SECURE_NO_WARNINGS)
else()
target_compile_options(vj_debug_views_test PRIVATE -Wall -Wextra -Wno-unused-parameter)
endif()
add_test(NAME debug_views_unit COMMAND vj_debug_views_test)
set_tests_properties(debug_views_unit PROPERTIES TIMEOUT 60 LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL ")
- Step 2: Run to see it fail.
Run: env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j4 --target vj_debug_views_test
Expected: FAIL, 'debug_views.h' file not found (and the .cpp missing).
-
Step 3: Implement
debug_views.h(the Interfaces declarations verbatim, includingViewBuffer::req,DisasmLine::bytes,DisasmFnandsetDisasm, plus a header comment of the lock discipline: one mutex, copied out before any iface call, installed under the lock with the slot'srevchecked;set()/clear()may be called from a posted lambda on the emulation thread) anddebug_views.cppper the semantics and table above. The installedDisasmFnis copied out under the lock with the request table and called with no lock held. Addsrc/debug/debug_views.cpptoVJ_APP_SOURCESaftersrc/debug/debug_session.cpp.debug_views.hincludes<atomic> <cstdint> <map> <mutex> <string> <vector>andvj_debug_iface.h. -
Step 4: Run to see it pass.
Run: cmake --build build -j4 --target vj_debug_views_test virtualjaguar && ctest --test-dir build -R debug_views_unit --output-on-failure
Expected: PASS, debug_views_test: OK.
- Step 5: Commit
git add app/src/debug/debug_views.h app/src/debug/debug_views.cpp app/tests/debug_views_test.cpp app/cmake/VJDebugTests.cmake app/CMakeLists.txt
git commit -F - <<'EOF'
feat(app): DebugViews, per-window requests the emulation thread fills (#967)
A request table with a double buffer per request: the UI sets and reads, the
emulation thread (inline: the same thread) fills after each frame. A changed
request or a content switch invalidates its buffer; no lock is held across a
core call.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task A3: DebugCore wrappers, and DebugSession marshalling (inline unchanged)
Files:
- Create:
app/src/debug/debug_core.h,app/src/debug/debug_core.cpp - Modify:
app/src/debug/debug_session.h(members :55-183),app/src/debug/debug_session.cpp(every iface use: :26-37, :82-106, :122-158pump, :162-218 breakpoints, :222-235tick, :239-275 memory helpers, :288-294regions) - Modify:
app/CMakeLists.txt(addsrc/debug/debug_core.cppafterdebug_session.cppat :340; beforeinclude(VJDebugCpu)at :501 addset(VJ_DEBUG_SESSION_SRCS src/debug/debug_session.cpp src/debug/debug_core.cpp src/debug/debug_views.cpp)),app/cmake/VJDebugTests.cmake(the ninesrc/debug/debug_session.cppentries at the targetsvj_debug_session_test,vj_debug_e2e,vj_debug_memory_test,vj_debug_memory_ui_test,vj_debug_scan_test,vj_debug_scan_e2e,vj_debug_scan_ui_smoke,vj_debug_op_test,vj_debug_op_e2ebecome${VJ_DEBUG_SESSION_SRCS}),app/cmake/VJDebugCpu.cmake(:37vj_debug_cpu_test, :50vj_debug_cpu_e2e: same replacement) - Test:
app/tests/debug_session_test.cpp
Interfaces:
- Consumes:
DebugViews(A2). - Produces:
DebugCore(iface wrappers,ifaceGen(), the transitionalpumpHook/markDetached(); the halt is B1), and onDebugSession:core(),views(),coreCall,corePost, the public helperscallCore(fn)/postCore(fn)(the windows call these, never the hooks),setHaltAvailable,haltAvailable,haltDisabledReason,toolAvailable(halt-aware),getRegsNow,setReg,buildCryTable,opSnapshotArm,blitLogEnable,texReplaceReload,fillViews,wakeViews,onCoreIface,syncCore;refreshSerial()becomes an atomic read. Signatures as in the Interfaces section.iface()andsetThreadBlocked()remain, unchanged, until A9. The windows' "same core" token isviews().epoch();ifaceGen()issyncCore()'s only.
Behaviour of the rewired DebugSession (inline, with coreCall/corePost empty, every existing assertion holds):
- Threading rule. UI-thread-only:
bps_,regions_,tools_,providers_,lastPokeError_,loaded_,hardcore_,haltAvailable_, the halt fields (halted_,queued_, ... still inline-only until B1),seenGen_,hadCore_. Any-thread:core_(atomics inside),views_,serial_(becomesstd::atomic<uint64_t>;refreshSerial()loads it,requestRefresh()and the bumpsfetch_add). hasCore()=core_.loaded();enabled()=core_.loaded() && loaded_ && !hardcore_ && !threadBlocked_;attached()=core_.attached().coreCall(f)/corePost(f). Public helperscallCore(f)/postCore(f)runfnow when the member is empty (the windows in A4-A8 call them too). Every method below that touches the iface goes through one of them with by-value captures except locals the caller waits for (coreCallis synchronous; a result local is captured by reference).
| Method | Marshalling |
|---|---|
attach() |
false unless enabled() && haltAvailable_; if core_.attached() true; else coreCall: core_.attach(), and on success core_.breakSet for a by-value copy of every enabled bps_ entry |
detach() |
halted_: detachAndResume() as today; else postCore(core_.detach()), queued_ = NONE |
breakNow(t) |
returns unless enabled() && !halted_ && haltAvailable_ and t valid; attach(); postCore(core_.haltNext(t)) |
addBreakpoint |
as today, but callCore(core_.breakSet); removeBreakpoint, setBreakpointEnabled, clearBreakpoints: postCore(core_.breakClear / breakSet) when core_.attached(); the bps_ list stays the UI source of truth. toggleBreakpoint inherits the gate through addBreakpoint |
read, readU, snapshotDram |
callCore(core_.peek); same results and out.clear() rules as today |
poke, pokeU |
ERR_STATE unless enabled(); flags as today; callCore(core_.poke); lastPokeError_ and the serial bump as today |
getRegsNow, setReg, buildCryTable, opSnapshotArm, blitLogEnable, texReplaceReload |
one callCore each to the core_ method of the same meaning |
regions() |
filled once per core by callCore looping core_.region(i, r) (<= 64); cleared in syncCore() |
fillViews() |
views_.fill(core_.iface(), serial_.load()), no marshalling (already on the emulation thread) |
wakeViews() |
if (!core_.loaded() || !loaded_) return; (not gated on hardcore: the tools it closes hold no requests, and Neuro's watches, 3c, must keep filling) if (!corePost) { fillViews(); return; } if (views_.pending(serial) && !wakePosted_.exchange(true)) corePost([this]{ wakePosted_ = false; fillViews(); }); |
onCoreIface(i) |
core_.setIface(i); views_.bumpEpoch(); |
syncCore() |
if core_.ifaceGen() == seenGen_ return; else seenGen_ = ...; dropHalt(); regions_.clear(); bps_.clear(); serial_++; and if (hadCore_ && !core_.loaded()) closeAllTools("no core"); hadCore_ = core_.loaded(); |
setIface(i) |
onCoreIface(i); syncCore(); |
setHaltAvailable(on) |
no-op if unchanged; stores; if (!on) detach(); bumps the serial. Does not close the tools |
toolAvailable(t) |
`enabled() && t.toggle && (!t.needsHalt |
pump(t, pc) |
the existing body, with core_.iface()->halt_reason (size-checked as today) and core_.markDetached() where it set attached_ = false on DETACH. Installed as core_.pumpHook in the constructor |
- Step 1: Write the failing tests. In
app/tests/debug_session_test.cppadd#include "debug_core.h"and, beforemain:
static void testCoreWrappers() {
MockDebugIface m;
DebugCore c;
uint8_t buf[4] = {};
vj_debug_regs none;
CHECK(c.peek(0, buf, 4) == VJ_DBG_ERR_STATE && !c.loaded() && !c.attach() && !c.getRegs(VJ_DBG_TARGET_M68K, none),
"a core with no interface: every wrapper returns its failure value");
c.setIface(m.iface());
CHECK(c.loaded() && c.iface() == m.iface() && c.ifaceGen() == 1, "setIface() stores the pointer and bumps the generation");
c.setIface(m.iface());
CHECK(c.ifaceGen() == 1, "the same pointer again is not a change");
CHECK(c.peek(0x10, buf, 4) == 4 && buf[0] == 0x10 && buf[3] == 0x13, "peek: the bus bytes");
const uint8_t w[2] = {0xAA, 0xBB};
CHECK(c.poke(0x7000, w, 2, 0) == 2 && c.poke(0x800000, w, 2, 0) == VJ_DBG_ERR_READONLY, "poke: the iface's own policy and result codes");
vj_debug_regs r;
CHECK(c.getRegs(VJ_DBG_TARGET_M68K, r) && r.d[3] == 0x1003 && c.setReg(VJ_DBG_TARGET_M68K, VJ_DBG_REG_68K_D0 + 3, 7) &&
c.getRegs(VJ_DBG_TARGET_M68K, r) && r.d[3] == 7, "registers read and write back");
std::string text;
unsigned len = 0;
CHECK(c.disasmRisc(VJ_DBG_TARGET_GPU, 0xF03006, text, len) && len == 6 && !text.empty() && !c.disasmRisc(VJ_DBG_TARGET_M68K, 0, text, len),
"disasmRisc: RISC only");
CHECK(c.attach() && c.attached() && m.attached() && c.attach(), "attach is idempotent");
CHECK(c.breakSet(VJ_DBG_TARGET_M68K, 0x4000) && m.breakpoints.size() == 1 && c.breakClear(VJ_DBG_TARGET_M68K, 0x4000), "break_set / break_clear");
c.detach();
CHECK(!c.attached() && !m.attached(), "detach() clears the core's side and ours");
std::vector<vj_debug_op_obj> objs;
unsigned hl = 0;
m.opReady = false;
CHECK(c.opSnapshotGet(objs, hl) == -1, "op snapshot: -1 while armed and not taken");
m.opReady = true;
c.opSnapshotArm();
CHECK(c.opSnapshotGet(objs, hl) == 4 && objs.size() == 4 && hl == 41, "op snapshot: four objects");
std::vector<uint32_t> cry;
CHECK(c.buildCryTable(cry) && cry.size() == 65536 && cry[0xFFFF] == 0xFFFFFFu && c.cryToRgb(0xFFFF) == 0xFFFFFFu, "the CRY table");
c.blitLogEnable(true);
vj_debug_blit b;
CHECK(m.blitLogOn && c.blitLogHead() == 5 && c.blitLogGet(4, b) && b.seq == 4 && !c.blitLogGet(5, b), "blit log");
CHECK(c.texReplaceReload() == 1 && m.texReloads == 1 && c.frameNumber() == 1234, "reload and frame number");
vj_debug_iface old = *m.iface();
old.texreplace_reload = nullptr;
DebugCore d;
d.setIface(&old);
CHECK(d.texReplaceReload() == -1, "texReplaceReload: -1 when the core does not offer it");
vj_debug_region reg;
CHECK(c.region(0, reg) && reg.name, "region");
c.setIface(nullptr);
CHECK(!c.loaded() && c.ifaceGen() == 2, "setIface(nullptr) drops the interface");
}
static void testMarshalling() {
DebugSession s;
MockDebugIface m;
int calls = 0, posts = 0;
s.coreCall = [&](std::function<void()> f) { calls++; f(); };
s.corePost = [&](std::function<void()> f) { posts++; f(); };
s.setIface(m.iface());
s.setContentLoaded(true);
s.registerPlaceholderTools();
auto delta = [&](int dc, int dp, const char* what) {
CHECK(calls == dc && posts == dp, "%s: %d coreCall / %d corePost (wanted %d / %d)", what, calls, posts, dc, dp);
calls = posts = 0;
};
std::vector<uint8_t> out;
unsigned u = 0;
vj_debug_regs r;
std::vector<uint32_t> cry;
CHECK(s.read(0x10, out, 4) && out[0] == 0x10); delta(1, 0, "read");
CHECK(s.readU(0x10, 2, u) && u == 0x1011); delta(1, 0, "readU");
CHECK(s.pokeU(0x7000, 4, 0xCAFEF00D) == 4); delta(1, 0, "poke");
CHECK(s.getRegsNow(VJ_DBG_TARGET_M68K, r)); delta(1, 0, "getRegsNow");
CHECK(s.setReg(VJ_DBG_TARGET_M68K, VJ_DBG_REG_68K_D0, 5)); delta(1, 0, "setReg");
CHECK(s.buildCryTable(cry)); delta(1, 0, "buildCryTable");
s.opSnapshotArm(); delta(1, 0, "opSnapshotArm");
s.blitLogEnable(true); delta(1, 0, "blitLogEnable");
CHECK(s.texReplaceReload() == 1); delta(1, 0, "texReplaceReload");
CHECK(!s.regions().empty()); delta(1, 0, "regions (once per core)");
s.regions(); delta(0, 0, "regions again: cached");
CHECK(s.attach()); delta(1, 0, "attach");
s.breakNow(); delta(0, 1, "breakNow when attached: only the halt_next post");
CHECK(s.addBreakpoint(VJ_DBG_TARGET_M68K, 0x4000)); delta(1, 0, "addBreakpoint");
s.removeBreakpoint(VJ_DBG_TARGET_M68K, 0x4000); delta(0, 1, "removeBreakpoint");
s.detach(); delta(0, 1, "detach");
// What the windows use every frame costs the core nothing.
s.tick(1.0, true);
s.views().set(DebugViews::kNoId, ViewRequest());
(void)s.refreshSerial(); (void)s.breakpoints(); (void)s.toolAvailable(*s.findTool("memory")); (void)s.enabled(); (void)s.attached();
delta(0, 0, "tick / views / serial / enabled / attached: no core call");
}
static void testHaltAvailable() {
DebugSession s;
MockDebugIface m;
s.setIface(m.iface());
s.setContentLoaded(true);
s.registerPlaceholderTools();
DebugTool memory = *s.findTool("memory");
memory.toggle = [] {};
s.registerTool(memory);
DebugTool step;
step.id = "step";
step.toggle = [] {};
step.needsHalt = true;
CHECK(s.attach() && s.attached() && s.haltAvailable() && s.haltDisabledReason().empty(), "available by default");
s.setHaltAvailable(false);
CHECK(!s.attached() && !m.attached(), "turning it off detaches");
CHECK(s.enabled() && s.toolAvailable(*s.findTool("memory")) && !s.toolAvailable(step),
"the windows stay live, a tool that needs a halt does not");
CHECK(!s.attach() && !m.attached(), "attach() refuses");
s.breakNow();
CHECK(!s.attached() && !m.attached(), "breakNow() refuses");
CHECK(!s.addBreakpoint(VJ_DBG_TARGET_M68K, 0x4000) && !s.toggleBreakpoint(VJ_DBG_TARGET_M68K, 0x4000) && s.breakpoints().empty(),
"addBreakpoint() and toggleBreakpoint() refuse");
CHECK(!s.haltDisabledReason().empty(), "with a reason");
std::vector<uint8_t> out;
CHECK(s.read(0x10, out, 4) && s.pokeU(0x7000, 4, 1) == 4, "memory reads and writes still work");
s.setHaltAvailable(true);
CHECK(s.attach() && s.addBreakpoint(VJ_DBG_TARGET_M68K, 0x4000), "and come back");
}
static void testCoreSyncSplit() {
// What the emulation thread does (onCoreIface) must not touch UI state; the UI adopts it in syncCore().
DebugSession s;
MockDebugIface m;
s.setIface(m.iface());
s.setContentLoaded(true);
CHECK(s.addBreakpoint(VJ_DBG_TARGET_M68K, 0x4000));
int closed = 0;
s.onForcedClose.push_back([&](const char*) { closed++; });
const uint64_t epoch = s.views().epoch();
s.onCoreIface(nullptr);
CHECK(!s.hasCore() && !s.enabled() && !s.attached(), "the atomics already say 'no core'");
CHECK(s.breakpoints().size() == 1 && closed == 0, "...but the UI-side list and callbacks are untouched until syncCore()");
CHECK(s.views().epoch() == epoch + 1, "the views' epoch moved on the emulation side");
s.syncCore();
CHECK(s.breakpoints().empty() && closed == 1, "syncCore() forgets the breakpoints and closes the tools once");
s.syncCore();
CHECK(closed == 1, "and is idempotent");
s.setIface(m.iface()); // the same static interface again, as a reload hands over
CHECK(s.enabled() && s.attach() && m.attached(), "a new core attaches");
}
static void testViewsFill() {
DebugSession s;
MockDebugIface m;
std::vector<std::function<void()>> posted;
s.corePost = [&](std::function<void()> f) { posted.push_back(std::move(f)); };
s.setIface(m.iface());
s.setContentLoaded(true);
ViewRequest r;
r.kind = ViewRequest::Kind::Memory;
r.addr = 0x1000;
r.len = 8;
const DebugViews::Id id = s.views().set(DebugViews::kNoId, r);
s.wakeViews();
s.wakeViews();
CHECK(posted.size() == 1, "wakeViews() posts one fill at a time (%zu)", posted.size());
posted[0]();
ViewBuffer b;
CHECK(s.views().read(id, b) && b.bytes[0] == 0x00 && b.serial == s.refreshSerial(), "the posted fill ran");
s.wakeViews();
CHECK(posted.size() == 1, "nothing pending: no post");
s.requestRefresh();
s.wakeViews();
CHECK(posted.size() == 2, "a new serial posts again");
posted[1]();
DebugSession t; // inline (no corePost): wakeViews() fills right away
t.setIface(m.iface());
t.setContentLoaded(true);
const DebugViews::Id id2 = t.views().set(DebugViews::kNoId, r);
t.wakeViews();
CHECK(t.views().read(id2, b), "inline: filled on the spot");
}
Call the five new tests from main before testIfaceSwap();. (testThreadBlocked stays until A9.) The mock is a process-wide singleton: every new test uses the one MockDebugIface m of its own function and never two at once.
- Step 2: Run to see it fail.
Run: cmake --build build -j4 --target vj_debug_session_test
Expected: FAIL, 'debug_core.h' file not found.
-
Step 3: Implement.
debug_core.h/.cpp: the frozenDebugCoreminus the halt section (halt(),haltSerial(),verdict,notifyVerdict,waitVerdict,runUiFrame,abortHalt,onHalt,onResume,kPumpWaitare B1), plus the change-request membersifaceGen(),pumpHook,markDetached(). Members:std::atomic<const vj_debug_iface*> iface_,std::atomic<bool> attached_,std::atomic<uint64_t> gen_,vj_debug_host host_(size,user = this,halt_pump = &haltPumpThunk).pump(t, pc)returnspumpHook ? pumpHook(t, pc) : VJ_DBG_RESUME_DETACH. Wrapper rules: each checks the iface and the function pointer it needs and returns the failure value of the frozen comment (peek/poke:VJ_DBG_ERR_STATE; bools: false;opSnapshotGet: 0;texReplaceReload: -1; counts: 0).setIface(i): no-op when equal; elseattached_ = false, store,gen_++.attach(): false without iface/attachpointer; true if already attached; elseiface->attach(&host_)and set the flag.detach(): calliface->detach()only when attached; clear.opSnapshotGet: aVJ_DBG_OP_MAX_OBJECTSbuffer on the heap,out.assign(buf, buf + n)forn > 0, elseout.clear(); returns the iface'sn.buildCryTable: 65536 calls ofcry_to_rgb. No ImGui, no SDL.debug_session.h/.cpp: as in the table above. Keephalted_,queued_,runUiFrame,abortHalt,onHalt,onResume,resumeQueued,setThreadBlocked,threadBlocked,iface()(nowcore_.iface()),disabledReason()unchanged. Update the header comment: the threading paragraph now lists which members are UI-only and which any-thread (the rule above).- CMake:
VJ_DEBUG_SESSION_SRCSand the eleven replacements listed under Files.
-
Step 4: Run to see it pass, and the whole debug suite.
Run: cmake --build build -j4 && ctest --test-dir build -R "debug_|smoke_yarc|emu_thread_limits" --output-on-failure
Expected: all PASS (inline behaviour unchanged: debug_session_unit, debug_e2e, debug_memory_unit, debug_memory_ui_smoke, debug_scan_unit, debug_scan_e2e, debug_scan_ui_smoke, debug_op_unit, debug_op_e2e, debug_cpu_unit, debug_cpu_e2e, debug_texture_unit, debug_blitter_unit, debug_views_unit). If a debug test is skipped for a missing ROM, the ROM path is ${VJ_ROMS}; do not edit the tests to make it pass.
- Step 5: Commit
git add app/src/debug/debug_core.h app/src/debug/debug_core.cpp app/src/debug/debug_session.h app/src/debug/debug_session.cpp app/tests/debug_session_test.cpp app/CMakeLists.txt app/cmake/VJDebugTests.cmake app/cmake/VJDebugCpu.cmake
git commit -F - <<'EOF'
feat(app): DebugCore owns the debug interface; DebugSession marshals to it (#967)
DebugCore wraps every vj_debug_iface entry point (emulation thread only);
DebugSession routes each action through coreCall/corePost, which run inline
until App wires them to the debug lane. setHaltAvailable gates attach, Break
and breakpoints so a threaded run can keep the windows and refuse the halt.
Inline behaviour is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task A4: Memory viewer and Palette read published buffers
The first window task also lands what A5-A8 reuse: the counting test fixture, the static "no direct iface" guard, and the rules every window follows from here on.
Window rules (A4-A8).
- A window draws only from
DebugSession::views().read()buffers, the session's UI-side state, and its own state. It never touchesvj_debug_ifaceand never callsread()/getRegsNow()from a path that runs every frame. - Every draw, after its
views().set()calls, callss.wakeViews()once. Inline that runs the copier at once (so the window sees its data on the nextread(), as it saw the synchronous peek before); threaded it is a debug-lane post that does nothing when no buffer is stale. - One-shot user actions (poke, edit, set_reg, breakpoints, reload) go through
s.callCore(fn)(waits) and fire-and-forget work throughs.postCore(fn). Never calls.coreCalldirectly: it is empty in tests. - A window that is closed, or whose
s.enabled()is false, clears its view ids (views().clear) so closed windows cost nothing. - "Same core" is
s.views().epoch(), not an iface pointer. A content switch therefore resets a window's selection; that is deliberate (a buffer must never show the previous game).
The names these tasks use are the Interfaces section's: wakeViews() (one posted fill while anything is pending; inline it fills now), ViewBuffer::req, DisasmLine::bytes and DebugViews::setDisasm (the CPU tool injects its disassembler, A5; without one a 68000 Disasm request answers ok = false), DebugSession::callCore / postCore (public helpers over the coreCall / corePost hooks), the session's onHalt / onResume (still on the session through 3a; B1 moves them to DebugCore, B2 adds onHaltUi), and ${VJ_DEBUG_SESSION_SRCS} (A3 defines it in app/CMakeLists.txt before include(VJDebugCpu); every test target that listed src/debug/debug_session.cpp lists it instead).
Files:
- Create:
app/tests/debug_ui_fixture.h,app/cmake/CheckNoDirectIface.cmake - Modify:
app/src/debug/memory_view.h(CryCodec175-189,SearchJob99-125),app/src/debug/memory_view.cpp(SearchJob::step263-297,CryCodec446-457),app/src/debug/memory_ui.h(test hook),app/src/debug/memory_ui.cpp(MemState25-57,fetchWindow86-104,syncCore107-120,drawGrid384-394 and 435-454,drawFooter503-545,drawMemoryWindow560-591,registerMemoryTools611-616),app/src/debug/clut_ui.cpp(ClutState23-42,refresh49-63,drawClutWindow88-107),app/tests/debug_memory_ui_test.cpp,app/tests/debug_memory_test.cpp,app/cmake/VJDebugTests.cmake
Interfaces:
- Consumes:
DebugViews::{set, clear, read, epoch, bumpEpoch},ViewBuffer::{ok, serial, req, bytes},DebugSession::{views, wakeViews, callCore, postCore, buildCryTable, read, readU, poke, regions}. - Produces:
// memory_view.h
class CryCodec {
public:
bool build(const vj_debug_iface* iface); // unchanged (emulation-thread side, and debug_memory_unit)
// The table through DebugSession::buildCryTable (one callCore), once per s.views().epoch(); a refusal is
// remembered for the epoch, so a core without cry_to_rgb does not retry every frame.
bool buildFrom(DebugSession& s);
private:
uint64_t epoch_ = ~0ull; bool refused_ = false; // beside the existing built_, table_
};
// SearchJob::step(budget): the whole budget loop runs inside ONE s_.callCore (nested read()s run inline on the
// emulation thread), not one wait per 64 KiB chunk.
// memory_ui.h
namespace memory_test { bool byteAt(unsigned addr, uint8_t& v); } // a byte of the window the grid last adopted
- Step 1: Test infrastructure shared by A4-A8.
app/tests/debug_ui_fixture.h (header-only, namespace vj):
class CountingIface { // a copy of an iface whose every entry point forwards and counts; one instance at a time
public:
enum Fn { Region, Peek, Poke, GetRegs, SetReg, DisasmRisc, Attach, Detach, BreakSet, BreakClear, HaltNext,
HaltedTarget, HaltReason, OpArm, OpGet, CryToRgb, BlitEnable, BlitHead, BlitGet, TexReload, FrameNo, kFns };
explicit CountingIface(const vj_debug_iface* inner); // one captureless thunk per vj_debug_iface function pointer
const vj_debug_iface* iface() const;
static unsigned count(Fn f);
static unsigned total();
static unsigned offCore(); // calls made while no OnCore guard was alive on the calling thread
static void reset(); // zero every counter (they are static)
struct OnCore { OnCore(); ~OnCore(); }; // thread_local depth: "this is the emulation thread"
};
struct UiFixture {
MockDebugIface mock;
CountingIface counted{mock.iface()};
DebugSession s;
unsigned coreCalls = 0, corePosts = 0; // outermost only: a call inside a call is the emulation thread's own
UiFixture(); // s.coreCall / s.corePost = lambdas that count (depth 0 only), hold an OnCore guard and run fn;
// s.setIface(counted.iface()); s.setContentLoaded(true)
void fill(); // OnCore guard + s.views().fill(s.core().iface(), s.refreshSerial()): what App::fillDebugViews does
struct Mark { unsigned total, coreCalls, corePosts; };
Mark mark() const; // CountingIface::total(), coreCalls, corePosts
bool quietSince(const Mark& m) const; // all three unchanged
};
app/cmake/CheckNoDirectIface.cmake: file(READ) each name in FILES under DIR, strip //... comments, and message(SEND_ERROR "<file>: direct core access <match>") for any match of iface\(\)|->(region|peek|poke|get_regs|set_reg|disasm_risc|attach|detach|break_set|break_clear|halt_next|halted_target|op_snapshot_arm|op_snapshot_get|cry_to_rgb|blit_log_enable|blit_log_head|blit_log_get|texreplace_reload|frame_number|halt_reason)\(|memoryRegion (CMake regexes have no \b: anchor on the call's (). op_ui.cpp legitimately asks app.host.inFrame() for its inline step gate, so inFrame is not in the pattern.
At the END of app/cmake/VJDebugTests.cmake:
# #967 3a: the tool windows never touch vj_debug_iface; every window task adds its files to this list.
set(VJ_NO_IFACE_FILES memory_ui.cpp clut_ui.cpp)
add_test(NAME debug_no_direct_iface COMMAND ${CMAKE_COMMAND} -DDIR=${CMAKE_CURRENT_SOURCE_DIR}/src/debug
"-DFILES=${VJ_NO_IFACE_FILES}" -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckNoDirectIface.cmake)
set_tests_properties(debug_no_direct_iface PROPERTIES LABELS emu_thread)
- Step 2: Write the failing tests.
app/tests/debug_memory_test.cpp (include debug_ui_fixture.h; call both from main after testClutReadBack()):
static void testCryBuildFrom() {
UiFixture f;
CryCodec c;
CHECK(c.buildFrom(f.s) && c.ready());
CHECK(CountingIface::count(CountingIface::CryToRgb) == 0x10000);
CHECK(c.decode(0xABCD) == (f.mock.iface()->cry_to_rgb(0xABCD) & 0xFFFFFF));
CHECK(c.buildFrom(f.s)); // same epoch: no rebuild
CHECK(CountingIface::count(CountingIface::CryToRgb) == 0x10000);
f.s.views().bumpEpoch(); // a new core / content
CHECK(c.buildFrom(f.s) && CountingIface::count(CountingIface::CryToRgb) == 2 * 0x10000);
vj_debug_iface noCry = *f.mock.iface(); // a core without cry_to_rgb
noCry.cry_to_rgb = nullptr;
f.s.setIface(&noCry); // (epoch bump, R3)
CryCodec none;
CHECK(!none.buildFrom(f.s) && !none.ready());
const unsigned k = f.coreCalls;
CHECK(!none.buildFrom(f.s) && f.coreCalls == k); // the refusal is remembered for the epoch
}
static void testSearchIsOneCoreCall() {
UiFixture f;
const std::vector<uint8_t> needle = {0xFA, 0xCE, 0xFE, 0xED};
for (size_t i = 0; i < needle.size(); i++) f.mock.dram()[0x180000 + i] = needle[i];
const vj_debug_region dram = f.s.regions()[0];
SearchJob j(f.s, dram, 0, needle);
const unsigned k = f.coreCalls;
CHECK(j.step(4u << 20) && j.found() && j.address() == 0x180000);
CHECK(f.coreCalls == k + 1); // 4 MiB of 64 KiB chunks, one wait
}
In the CryCodec refusal case copy the mock's table into a vj_debug_iface whose cry_to_rgb is null and hand it to bare.s.setIface(); the SearchJob rig's region 0 is DRAM as in testSearch. Call CountingIface::reset() at the top of testCryBuildFrom (the counters are static). UiFixture sets CountingIface's own table as the session's iface, so the section above compares against the raw mock only for values, never for counts.
app/tests/debug_memory_ui_test.cpp: replace MockDebugIface mock; DebugSession s; s.setIface(...); s.setContentLoaded(true); with UiFixture fx; DebugSession& s = fx.s; MockDebugIface& mock = fx.mock;, add a file-static UiFixture* g_fx = &fx; and make frame() call g_fx->fill() after acknowledgeTextures(). Sections 1-7 stay as they are. Insert the new sections between section 6 and section 7 (section 7 detaches the core and closes both windows):
// 8. #967 3a: the draw path never reaches the core. After a settle, idle frames make no coreCall, no corePost,
// and the counting iface saw no call outside the emulation side at all.
CountingIface::reset();
memoryWindowOpen() = true;
clutWindowOpen() = true;
showMemoryAt(s, 0x1000);
settle(s, 6);
const UiFixture::Mark quiet = fx.mark();
for (int i = 0; i < 30; i++) frame(s);
CHECK(fx.quietSince(quiet));
CHECK(CountingIface::offCore() == 0);
// 9. The bytes on screen are the published ones: change RAM behind the interface; nothing shows until a refresh.
mock.dram()[0x1002] = 0x5A;
uint8_t v = 0;
CHECK(memory_test::byteAt(0x1002, v) && v != 0x5A);
s.requestRefresh();
settle(s, 2);
CHECK(memory_test::byteAt(0x1002, v) && v == 0x5A);
CHECK(fx.mark().total > quiet.total);
// 10. Scrolling far away asks for a new window, and keeps drawing the old rows until it arrives.
showMemoryAt(s, 0x100005);
settle(s, 8);
CHECK(memory_test::byteAt(0x100005, v) && v == 5);
CHECK(CountingIface::offCore() == 0);
// 11. The CLUT bytes arrive through one request (both tables), and idle Palette frames stay quiet.
mock.regwin()[0x400] = 0x12;
s.requestRefresh();
settle(s, 3);
const UiFixture::Mark q2 = fx.mark();
settle(s, 20);
CHECK(fx.quietSince(q2));
Section 8's offCore() == 0 covers sections 1-6 as well when CountingIface::reset() is moved to the top of main (do that): the pokes of sections 1-5 go through callCore, so they are on-core.
- Step 3: Run, expect FAIL.
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4 --target vj_debug_memory_test vj_debug_memory_ui_test
Expected: compile errors (buildFrom, memory_test::byteAt, UiFixture members) and, once those exist, debug_memory_ui_smoke failing sections 8-10 (the window still peeks from the draw path). Run ctest --test-dir build -R debug_no_direct_iface on the still-unmodified memory_ui.cpp / clut_ui.cpp and see it FAIL naming iface() and ->...(: that is the only proof the guard can fail.
- Step 4: Implement.
memory_view.cpp: CryCodec::buildFrom as specified. SearchJob::step(budget): move the current body into a private stepOnCore(budget) and make step run it inside s_.callCore. pollSearch keeps calling m.job->step(4u << 20).
memory_ui.cpp window state (MemState): drop ifaceSeen; add epochSeen = ~0ull, DebugViews::Id winId, selId = DebugViews::kNoId, unsigned reqStart = 0, reqLen = 0, selReq = ~0u. Add void releaseViews(DebugSession&) (clear both ids, reqLen = 0, selReq = ~0u).
syncCore: comparem.epochSeenwiths.views().epoch(); the reset body is unchanged pluswinSerial = 0.fetchWindow(s, r, visStart, visLen)replaces the synchronous read. Wanted span = the visible rows pluskMarginRows = 16rows each side, clamped byclampToRegionand toDebugViews::kMaxMemoryBytes. Calls.views().set(m.winId, {Memory, spanStart, spanLen})only whenwinId == kNoId, the visible rows leave[reqStart, reqStart+reqLen), orperRowchanged. ThenViewBuffer b; if (s.views().read(m.winId, b) && b.req.addr == m.reqStart): whenb.okand (b.serial != m.winSerialorb.req.addr != m.winStart) adopt it (m.win = std::move(b.bytes),m.winStart = b.req.addr,m.track.update(m.winStart, data, size, /*refresh=*/b.serial != m.winSerial),winOk = true,winSerial = b.serial); when!b.oksetwinOk = false. A request not yet answered leaves the previous window drawn (cells outside it show?).drawFooter: replace the per-frames.read(m.sel, b, 4)with a second Memory request{sel, 4}(selId, re-set whenm.selchanges). Draw the cell line from the buffer whenreq.addr == m.sel && ok; "reading..." until answered; the existing "unreadable" text when answered with!ok.beginEditand Follow pointer keep their one-shots.read/s.readU(user actions).drawMemoryWindow: when!m.openor!s.enabled()callreleaseViews(s)before returning; calls.wakeViews()after the grid and footer have set their requests.registerMemoryTools' forced-close lambda captures&sand callsreleaseViews(s).memory_test::byteAt: true whenwinOkandaddrlies in[winStart, winStart + win.size()).
clut_ui.cpp: one Memory request {kClutA, 2 * kClutEntries * 2} (table B is table A + 0x200: static_assert(kClutB == kClutA + kClutEntries * 2)), set on first draw and cleared like the memory ids (closed / disabled / forced close). refresh adopts the buffer when b.serial != c.serial: c.ok = b.ok && b.bytes.size() == 0x400, A from bytes [0, 0x200), B from [0x200, 0x400). ifaceSeen becomes epochSeen on views().epoch(). c.cry.buildFrom(s) replaces c.cry.build(s.iface()). Call s.wakeViews() after the request is set.
- Step 5: Run, expect PASS.
cmake --build build -j4 --target vj_debug_memory_test vj_debug_memory_ui_test virtualjaguar && ctest --test-dir build -R 'debug_memory|debug_no_direct_iface|debug_session_unit' --output-on-failure
Expected: debug_memory_unit, debug_memory_ui_smoke, debug_no_direct_iface, debug_session_unit pass (the old checks of debug_memory_unit for CryCodec::build(iface), SearchJob, readU are untouched).
- Step 6: Commit.
git add app/tests/debug_ui_fixture.h app/cmake/CheckNoDirectIface.cmake app/cmake/VJDebugTests.cmake app/src/debug/memory_view.h app/src/debug/memory_view.cpp app/src/debug/memory_ui.h app/src/debug/memory_ui.cpp app/src/debug/clut_ui.cpp app/tests/debug_memory_test.cpp app/tests/debug_memory_ui_test.cpp
git commit -m "feat(app): Memory viewer and Palette read published view buffers (#967)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task A5: CPU debugger on Regs and Disasm requests
Files:
- Create:
app/src/debug/core_job.h,app/tests/debug_core_job_test.cpp,app/tests/debug_cpu_ui_smoke.cpp - Modify:
app/src/debug/cpu_view.h(afterparseHex116;TempBreak/CpuControl118-170),app/src/debug/cpu_view.cpp(TempBreak338-358,CpuControl::stepIn400-418,stepOver420-444),app/src/debug/cpu_ui.h,app/src/debug/cpu_ui.cpp(TabState25-51,refreshRegs74-84,setReg104-113,drawDisasm280-330,drawBanner455-483,drawToolbar485-512,drawWindow545-617,registerCpuTools631-669),app/tests/debug_cpu_test.cpp,app/cmake/VJDebugTests.cmake,app/cmake/VJDebugCpu.cmake
Interfaces:
- Consumes:
ViewRequest::Kind::{Regs, Disasm},ViewBuffer::{regs, lines, req, ok, serial},DebugViews::setDisasm(R4),DebugSession::{views, wakeViews, callCore, postCore, core, setReg, haltAvailable, requestRefresh},DebugCore::{breakSet, breakClear, haltNext, getRegs, iface}. - Produces:
// core_job.h (header-only; no ImGui): work for the emulation thread whose result the UI picks up later.
template <class T> class CoreJob {
public:
// UI thread. Posts fn(session.core().iface()) through postCore; inline it has run before submit() returns.
void submit(DebugSession& s, std::function<T(const vj_debug_iface*)> fn);
bool take(T& out); // UI thread: true once per delivered result; newest wins; a result is never given twice
bool pending() const; // submitted, not yet delivered
};
// cpu_view.h
std::vector<DisasmLine> disasmRows(const vj_debug_iface* iface, int target, unsigned addr, unsigned rows); // = disasmForward mapped to DisasmLine (len 0 for an invalid line)
class CpuFeed { // what cpu_ui draws from: published buffers only
public:
void want(DebugSession& s, int target, unsigned top, unsigned rows); // views().set for Regs{target} and Disasm{target, top, rows}, only when changed
bool update(DebugSession& s); // adopts the newest buffers; true when regs or rows changed
void release(DebugSession& s);
bool answered() const; // some Regs buffer has been adopted (ok or not)
bool haveRegs() const; int regsTarget() const; const vj_debug_regs& regs() const;
bool rowsFor(int target, unsigned top, unsigned rows) const; // the adopted rows answer exactly this request
const std::vector<DisasmLine>& rows() const;
};
class DisasmNav { // the synchronous walks (backStart / prevAddr) moved onto the emulation thread
public:
void back(DebugSession& s, int target, unsigned pc, int lines); // result: backStart(iface, target, pc, lines)
void prev(DebugSession& s, int target, unsigned from, int count); // result: prevAddr applied `count` times
bool take(unsigned& addr);
bool pending() const;
};
// cpu_ui.h
namespace cpu_test { bool haveRegs(); size_t rowCount(); }
- Step 1: Write the failing tests.
app/tests/debug_core_job_test.cpp (new executable, LABELS emu_thread, links ${VJ_DEBUG_SESSION_SRCS}, Threads::Threads):
// A deferred corePost: the job has not run until the "emulation thread" drains the queue.
std::vector<std::function<void()>> q;
s.corePost = [&](std::function<void()> fn) { q.push_back(std::move(fn)); };
CoreJob<int> job;
job.submit(s, [](const vj_debug_iface*) { return 7; });
int out = 0;
CHECK(job.pending() && !job.take(out) && q.size() == 1);
std::thread emu([&] { for (auto& f : q) f(); }); // runs on another thread
emu.join();
CHECK(!job.pending() && job.take(out) && out == 7 && !job.take(out));
job.submit(s, [](const vj_debug_iface*) { return 1; });
job.submit(s, [](const vj_debug_iface*) { return 2; });
for (size_t i = 1; i < q.size(); i++) q[i](); // q[0] is the already-run job; the two new ones run in order
CHECK(job.take(out) && out == 2); // newest wins
s.corePost = nullptr; // inline: runs inside submit()
job.submit(s, [](const vj_debug_iface* i) { return i == nullptr ? 9 : 8; });
CHECK(job.take(out) && out == 9); // no core attached: iface is null
app/tests/debug_cpu_test.cpp (add, call from main; include debug_ui_fixture.h; link the test with ${VJ_DEBUG_SESSION_SRCS}):
static void testCpuFeedReadsBuffersOnly() {
UiFixture f;
f.s.views().setDisasm(&disasmRows);
CpuFeed feed;
feed.want(f.s, VJ_DBG_TARGET_M68K, 0x1000, 8);
f.s.wakeViews(); // inline: the copier runs now
const UiFixture::Mark m = f.mark();
CHECK(feed.update(f.s) && feed.haveRegs() && feed.regsTarget() == VJ_DBG_TARGET_M68K);
CHECK(feed.regs().pc == 0x4000 && feed.regs().d[3] == 0x1003);
CHECK(feed.rowsFor(VJ_DBG_TARGET_M68K, 0x1000, 8) && feed.rows().size() == 8);
CHECK(feed.rows()[0].addr == 0x1000 && !feed.rows()[0].bytes.empty());
for (size_t i = 1; i < 8; i++) CHECK(feed.rows()[i].addr == feed.rows()[i - 1].addr + feed.rows()[i - 1].len);
CHECK(!feed.update(f.s) && !feed.update(f.s)); // nothing new
CHECK(f.quietSince(m)); // update() never reaches the core
feed.want(f.s, VJ_DBG_TARGET_GPU, 0xF03000, 4); // a tab switch: the old answer is not the new one
CHECK(!feed.rowsFor(VJ_DBG_TARGET_GPU, 0xF03000, 4) && feed.regsTarget() == VJ_DBG_TARGET_M68K);
f.s.wakeViews();
CHECK(feed.update(f.s) && feed.regsTarget() == VJ_DBG_TARGET_GPU && feed.regs().risc_pc == 0xF03000);
CHECK(feed.rowsFor(VJ_DBG_TARGET_GPU, 0xF03000, 4) && feed.rows()[0].text.find("fake") == 0);
}
static void testDisasmNav() {
UiFixture f;
std::vector<std::function<void()>> q;
f.s.corePost = [&](std::function<void()> fn) { q.push_back(std::move(fn)); };
DisasmNav nav;
nav.back(f.s, VJ_DBG_TARGET_M68K, 0x1010, 4);
unsigned a = 0;
CHECK(nav.pending() && !nav.take(a));
std::vector<std::function<void()>> work = std::move(q);
for (auto& fn : work) { CountingIface::OnCore g; fn(); }
CHECK(nav.take(a) && a == backStart(f.mock.iface(), VJ_DBG_TARGET_M68K, 0x1010, 4));
}
app/tests/debug_cpu_ui_smoke.cpp (new, modelled on debug_memory_ui_test.cpp; UiFixture; registerCpuTools(s); s.findTool("cpu")->toggle(); INI size for "CPU debugger" 900x560):
settle(8); // frame() = NewFrame, drawCpuTools(s), Render, fx.fill()
CHECK(cpu_test::haveRegs() && cpu_test::rowCount() >= 4);
const UiFixture::Mark quiet = fx.mark();
for (int i = 0; i < 30; i++) frame(s);
CHECK(fx.quietSince(quiet) && CountingIface::offCore() == 0);
showCpuAt(s, VJ_DBG_TARGET_GPU, 0xF03000); // the other tab: new requests, answered after a fill
settle(8);
CHECK(cpu_test::haveRegs() && CountingIface::offCore() == 0);
s.setHaltAvailable(false); // the threaded 3a state: windows live, Break/Step greyed
settle(4);
CHECK(CountingIface::offCore() == 0);
- Step 2: Run, expect FAIL.
cmake --build build -j4 --target vj_debug_cpu_test vj_debug_core_job_test vj_debug_cpu_ui_smoke
Expected: compile errors (CoreJob, CpuFeed, DisasmNav, disasmRows, cpu_test::); after those exist debug_cpu_ui_smoke fails on offCore() == 0 while cpu_ui.cpp still calls get_regs.
- Step 3: Register the tests.
VJDebugTests.cmake: define the smoke helper (A7 and A8 use it too) near the top of the file, after VJ_DEBUG_SESSION_SRCS is available:
function(vj_add_debug_ui_smoke target test) # remaining arguments: the test's own sources
add_executable(${target} ${ARGN} ${VJ_DEBUG_SESSION_SRCS} src/cheats.cpp src/config.cpp src/paths.cpp
"${VJ_ROOT}/src/core/cheat.c" src/theme.cpp src/theme_style.cpp src/theme_custom.cpp)
target_include_directories(${target} PRIVATE src src/debug tests "${VJ_ROOT}/src/debug" "${VJ_ROOT}/src/core"
"${VJ_ROOT}/libretro-common/include")
target_link_libraries(${target} PRIVATE vj_imgui ${VJ_SDL3_TARGET})
if(MSVC)
target_compile_options(${target} PRIVATE /W3 /utf-8)
target_compile_definitions(${target} PRIVATE _CRT_SECURE_NO_WARNINGS)
else()
target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter)
endif()
add_test(NAME ${test} COMMAND ${target})
set_tests_properties(${test} PROPERTIES TIMEOUT 60 FAIL_REGULAR_EXPRESSION "FAIL " LABELS emu_thread)
endfunction()
debug_core_job_unit: add_executable(vj_debug_core_job_test tests/debug_core_job_test.cpp ${VJ_DEBUG_SESSION_SRCS}), include dirs src/debug tests "${VJ_ROOT}/src/debug", Threads::Threads, LABELS emu_thread, FAIL_REGULAR_EXPRESSION "FAIL ". VJDebugCpu.cmake is included before VJDebugTests.cmake, so the helper does not exist there: leave vj_debug_cpu_test and vj_debug_cpu_e2e in VJDebugCpu.cmake (they take ${VJ_DEBUG_SESSION_SRCS} per R7) and define the new smoke in VJDebugTests.cmake: vj_add_debug_ui_smoke(vj_debug_cpu_ui_smoke debug_cpu_ui_smoke tests/debug_cpu_ui_smoke.cpp src/debug/cpu_ui.cpp) followed by target_link_libraries(vj_debug_cpu_ui_smoke PRIVATE vj_debug_cpu). Add cpu_ui.cpp to VJ_NO_IFACE_FILES (not cpu_view.cpp: its disassembler functions take the iface by design and run on the emulation side).
- Step 4: Implement.
core_job.h: a std::shared_ptr<State> (mutex, optional T, std::atomic<unsigned> inFlight) captured by value in the posted lambda, which also captures &s.core(); take moves the value out under the mutex.
cpu_view.cpp:
disasmRows=disasmForward(iface, target, addr, rows)mapped line by line:{addr, valid ? len : 0, text, bytes}.CpuFeed: idsregsId_,disId_;wantcallsviews().setonly for a changed field;updatereads both buffers, adopts a Regs buffer when its serial differs from the adopted one, a Disasm buffer whenreqdiffers from the adopted request or its serial moved.TempBreak::arm: afters.attach(), decideuserArmedfroms.breakpoints()(UI list) as today, thens.callCore([&]{ ok = s.core().breakSet(target, addr); });disarm:if (active_ && owned_ && s.attached()) s.callCore([&]{ s.core().breakClear(target_, addr_); }).CpuControl::stepIn(other processor):s_.callCore([&]{ ok = s_.core().iface() != nullptr; if (ok) s_.core().haltNext(tab); }), return false when!ok.stepOver: onecallCorecomputinggetRegs+stepOverTarget(s_.core().iface(), tab, r.pc)into locals, then the existing branches.DisasmNav::back/prev=CoreJob<unsigned>overbackStart/prevAddrx count.
cpu_ui.cpp:
-
TabState: dropregsSerial,rowsSerial,rowsTop,rowsN,rowsIface;rowsbecomesstd::vector<DisasmLine>(L.validbecomesL.len != 0); addunsigned wheelUp = 0; bool navBusy = false;. OneCpuFeed feedand oneDisasmNav navinCpuState. -
refreshRegsis replaced by an adopt step indrawWindow:g.feed.want(s, tab, t.top, n); g.feed.update(s); if (g.feed.haveRegs() && g.feed.regsTarget() == tab) { t.regs = g.feed.regs(); t.haveRegs = true; };s.wakeViews()afterwant. While!g.feed.answered()the window shows "Reading registers..."; the existing "The core did not return..." text appears only for an answered!okbuffer. -
drawDisasm:rowscome fromfeed.rows()whenfeed.rowsFor(target, t.top, n), else the previous rows stay. Wheel down:t.top = rows[whole].addrfrom the published rows (clamped to the last row). Wheel up: add tot.wheelUp; when!navBusypostnav.prev(s, target, t.top, t.wheelUp)and clear it; onnav.takesett.top, clearnavBusy, and post again whenwheelUpaccumulated meanwhile. Follow-PC: when the PC row is not visible postnav.back(s, target, pc, min(4, n / 4)); on take settop. Initial!topValid: postnav.backand draw an empty list until it returns (inline: the same call).nextAddr/prevAddr/backStartare no longer called from the draw path. -
setRegusess.setReg(target, reg, v)(acallCoreinside) and keepss.requestRefresh().drawToolbar: Break is enabled when!halted && s.haltAvailable(); Step/Continue/Step over/Run to cursor keep requiringhalted; the tooltip of a disabled Break says "Halting is not available with the emulation thread yet" when!s.haltAvailable(). -
registerCpuTools:s.views().setDisasm(&disasmRows); theonHalthook (R5) keepst.prev = t.regsand addss.requestRefresh()in place oft.regsSerial = 0; the forced-close hook also callsg.feed.release(s).drawWindowreleases the feed when the window is closed or!s.enabled(). -
cpu_test::haveRegs/rowCountreadg.t[g.tab]. -
Step 5: Run, expect PASS.
cmake --build build -j4 --target vj_debug_cpu_test vj_debug_core_job_test vj_debug_cpu_ui_smoke vj_debug_cpu_e2e virtualjaguar && ctest --test-dir build -R 'debug_cpu|debug_core_job|debug_no_direct_iface|debug_session_unit' --output-on-failure
Expected: all pass, including the untouched debug_cpu_unit run-control checks and debug_cpu_e2e (inline: callCore runs at once, so break / step / step over / run to cursor behave as before).
- Step 6: Commit.
git add app/src/debug/core_job.h app/src/debug/cpu_view.h app/src/debug/cpu_view.cpp app/src/debug/cpu_ui.h app/src/debug/cpu_ui.cpp app/tests/debug_core_job_test.cpp app/tests/debug_cpu_test.cpp app/tests/debug_cpu_ui_smoke.cpp app/cmake/VJDebugTests.cmake app/cmake/VJDebugCpu.cmake
git commit -m "feat(app): CPU debugger reads Regs and Disasm view buffers (#967)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task A6: Object list on the OpSnapshot request
Files:
- Modify:
app/src/debug/op_view.h(OpViewModel16-90),app/src/debug/op_view.cpp(all of it, 190 lines),app/src/debug/op_render.h(afteropColorToRgb62),app/src/debug/op_render.cpp(opColorToRgb56-69),app/src/debug/op_ui.cpp(drawPaintControls259-317,drawObjectsTool472-497,drawObjectsWindow504-634),app/tests/debug_op_test.cpp,app/cmake/VJDebugTests.cmake
Interfaces:
- Consumes:
ViewRequest::Kind::{OpSnapshot, Memory},ViewBuffer::{opCount, objs, halfline, req, ok},CoreJob(A5),DebugSession::{views, wakeViews, callCore, postCore, requestRefresh, buildCryTable, poke, regions},App::emu.advance. - Produces:
// op_view.h
enum class OpStep { None, Inline, Advance };
struct OpStepGate { bool threaded, paused, halted, attached, inFrame, loaded, netplay; };
// Advance: threaded && paused && !halted && loaded && !netplay (emu.advance(1); inFrame and the owner-only host
// calls are never consulted). Inline: today's rule (!threaded && paused && !attached && !halted && !inFrame &&
// loaded && !netplay). None otherwise.
OpStep opStepFor(const OpStepGate& g);
class OpViewModel {
public:
void arm(DebugSession& s); // postCore { opSnapshotArm(); views().set(snap, since=++armGen_) }
bool update(DebugSession& s, bool running = true); // views().read only; may postCore a live arm, a render, a walk
OpScreenGeom readGeom(unsigned frameW, unsigned frameH) const; // from the last adopted $F00028 request; no session
void releaseViews(DebugSession& s);
const std::vector<uint32_t>* cryTable() const; // the 64K table, or null until update() built it (one callCore per epoch, the first time a bitmap is selected)
};
// op_render.h
uint32_t opColorToRgb(const std::vector<uint32_t>* cryTable, OpColorMode mode, unsigned v16); // same maths, table instead of iface
- Step 1: Write the failing tests.
In app/tests/debug_op_test.cpp (include debug_ui_fixture.h; link debug_op_unit with ${VJ_DEBUG_SESSION_SRCS}; the existing testViewModel* functions stay and must pass unchanged, because inline postCore/wakeViews run at once); call the new ones from main:
static void testOpStepGate() {
OpStepGate g{}; // all false
g.loaded = true; g.paused = true;
CHECK(opStepFor(g) == OpStep::Inline);
g.attached = true; CHECK(opStepFor(g) == OpStep::None); g.attached = false;
g.inFrame = true; CHECK(opStepFor(g) == OpStep::None); g.inFrame = false;
g.threaded = true; CHECK(opStepFor(g) == OpStep::Advance); // inFrame is not consulted threaded
g.inFrame = true; CHECK(opStepFor(g) == OpStep::Advance); g.inFrame = false;
g.halted = true; CHECK(opStepFor(g) == OpStep::None); g.halted = false;
g.netplay = true; CHECK(opStepFor(g) == OpStep::None); g.netplay = false;
g.paused = false; CHECK(opStepFor(g) == OpStep::None);
}
static void testViewModelNeverTouchesTheCore() {
UiFixture f;
OpViewModel vm;
vm.update(f.s); vm.update(f.s); // arm + first absorb, as testViewModelLifecycle
CHECK(vm.haveSnapshot() && vm.objects().size() == 4);
CHECK(CountingIface::offCore() == 0);
const UiFixture::Mark m = f.mark();
for (int i = 0; i < 30; i++) vm.update(f.s); // idle: no new serial, nothing waiting
CHECK(f.quietSince(m));
f.s.requestRefresh();
vm.update(f.s); // a live tick: one arm (post) and one fill, off-core zero
CHECK(f.corePosts > m.corePosts && CountingIface::offCore() == 0);
}
static void testRenderLeavesTheUiThread() {
UiFixture f;
std::vector<std::function<void()>> q; // a deferred emulation thread
f.s.corePost = [&](std::function<void()> fn) { q.push_back(std::move(fn)); };
auto drain = [&] { // what the emulation thread does at its next boundary
std::vector<std::function<void()>> work = std::move(q);
q.clear();
for (auto& fn : work) { CountingIface::OnCore g; fn(); }
};
OpViewModel vm;
vm.update(f.s); // queues the fill (snapshot request not answered yet), then the arm
drain(); // fill, then the arm applied (the request is reset after it)
vm.update(f.s); // a fill is pending again: queued
drain(); // the snapshot is filled
vm.update(f.s); // absorbs; queues the first render
const unsigned peeks = CountingIface::count(CountingIface::Peek);
CHECK(vm.haveSnapshot() && vm.image() == nullptr); // not rendered yet: nothing ran on this thread
CHECK(CountingIface::offCore() == 0);
drain();
CHECK(CountingIface::count(CountingIface::Peek) > peeks);
vm.update(f.s);
CHECK(vm.image() != nullptr && vm.image()->w == 16);
}
static void testPaintIsOneCoreCall() {
UiFixture f;
OpViewModel vm;
vm.update(f.s); vm.update(f.s);
std::string st;
const unsigned k = f.coreCalls;
CHECK(vm.paint(f.s, 3, 2, 200, st));
CHECK(f.coreCalls == k + 1 && f.mock.dram()[MockDebugIface::kBitmapData + 35] == 200);
CHECK(vm.image()->rawAt(3, 2) == 200 && CountingIface::offCore() == 0);
}
static void testGeomAndColourFromBuffers() {
UiFixture f;
f.mock.regwin()[0x28] = 0x00; f.mock.regwin()[0x29] = 0x07; // VMODE
f.mock.regwin()[0x38] = 0x01; f.mock.regwin()[0x39] = 0x80; // HDB1
OpViewModel vm;
vm.update(f.s); vm.update(f.s);
OpScreenGeom g = vm.readGeom(320, 240);
CHECK(g.vmode == 0x0007 && g.hdb1 == 0x180 && g.frameW == 320);
std::vector<uint32_t> table;
CHECK(f.s.buildCryTable(table));
for (OpColorMode m : {OpColorMode::Cry, OpColorMode::Rgb16, OpColorMode::Mixed})
for (unsigned v : {0x0000u, 0x1234u, 0x1235u, 0xFFFFu})
CHECK(opColorToRgb(&table, m, v) == opColorToRgb(f.mock.iface(), m, v));
}
In app/tests/debug_op_e2e.cpp nothing changes (inline: the arm is applied, the fill runs in postCore/wakeViews, and update() re-requests while an armed snapshot is still -1). The existing vm.update(dbg) / host.runFrame() / vm.update(dbg) sequence must pass as is.
- Step 2: Run, expect FAIL.
cmake --build build -j4 --target vj_debug_op_test vj_debug_op_e2e
Expected: compile errors (opStepFor, OpStep, readGeom(unsigned, unsigned), the table overload).
- Step 3: Implement.
op_render.{h,cpp}: the table overload (Rgb16: the existing lambda; Cry: table && table->size() == 0x10000 ? (*table)[v] : 0; Mixed: bit 0 picks). The iface overload stays (the render job and opRender run on the emulation thread) and now calls the table maths for Rgb16.
op_view.{h,cpp} (OpViewModel):
- State added:
DebugViews::Id snapId_, geomId_;uint64_t armGen_ = 0;uint64_t epochSeen_;CoreJob<RenderOut> render_(RenderOut {uint64_t gen; bool ok; OpImage img; std::string err;}) withrenderGen_;CoreJob<WalkOut> walk_(WalkOut {uint64_t gen; std::vector<OpObject> objs;}) with a heldpendingSnap_(decoded snapshot objects + halfline + count) used by the fallback;std::vector<uint32_t> cry_; geometry bytesgeom_[0x20]+geomOk_. arm(s): returns at once when!s.enabled()(the hardcore checks oftestViewModelLifecyclecountopArms); ensuressnapId_exists on this thread first (snapId_ = views().set(snapId_, {OpSnapshot, since = armGen_}), so the lambda below never allocates an id the model does not know); then++armGen_;waiting_ = true; ++arms_;s.postCore([=]{ s.core().opSnapshotArm(); s.views().set(snapId_, {OpSnapshot, since = gen}); }), so the buffer is reset after the arm applied (a buffer filled before the arm can never be mistaken for its answer). NorequestRefresh()here.update(s, running)runs in this order: (1) return false when!s.enabled(); (2) ensuresnapId_(since = armGen_) andgeomId_({Memory, 0xF00028, 0x20}) exist; (3)s.wakeViews(); (4) read both buffers and absorb whenwaiting_ && b.ok && b.req.since == armGen_ && b.opCount >= 0; (5) whilewaiting_,runningand the buffer still readsopCount == -1, re-set()the snapshot request with a poll counter in a field OpSnapshot ignores (addr = ++pollGen_): R2 makes the changed request pending,wakeViews()refills just that buffer (inline the walk is then visible on a second read; threaded the next frame), and no serial moves, so the other windows' change highlights and the live re-arm are untouched; (6) last, the first/live arm on a serial change exactly as today (armLive= the same post). The arm comes last sowaiting()is true right after the firstupdate(), astestViewModelLifecycleasserts. A new epoch callsclear().- Fallback walk (
followLinks && count == 0):walk_.submitof a job that reads OLP lo/hi withiface->peek(0xF00020/22, ...)and runsopWalkList(&peekReader, iface, lo | (hi << 16), VJ_DBG_OP_MAX_OBJECTS); the snapshot is committed (selection re-find, auto-select,imageDirty_) whenwalk_.takedelivers, otherwise immediately. rerenderbecomesstartRender(s)+applyRender(): when the selected object is not a bitmap resolve at once; elserender_.submit(s, [obj, opt, gen](const vj_debug_iface* i){ opRender(...) }), andapplyRender()takes the result, drops it whengen != renderGen_, and applies the existingdiffers/imgSerial_logic.paint()calls both in sequence (inline it completes before returning).paint(s, x, y, value, status): ones.callCorecomputingopPixelPatch(s.core().iface(), ...), the RAM-region check againsts.regions(), ands.poke(...)(nested, inline on the emulation thread); the status strings are unchanged.readGeom(frameW, frameH): big-endian words of the{0xF00028, 0x20}buffer at offsets0x00(VMODE, whole),0x10(HDB1),0x16(VP),0x1E(VDB), the last three& 0x7FFas today; default geometry when!geomOk_.releaseViews(s): clearssnapId_,geomId_.cryTable()buildscry_throughs.buildCryTableonce per epoch (onecallCore) when first asked for with a bitmap object selected.
op_ui.cpp:
-
drawPaintControls(const std::vector<uint32_t>* cry, ...)usesopColorToRgb(cry, img.mode, w); the caller passesvm.cryTable(). -
drawObjectsWindow:vm.update(session, ctx.emulationRunning); the geometry block callsvm.readGeom(ctx.frameW, ctx.frameH); when the window is closed or!session.enabled()callvm.releaseViews(session). -
drawObjectsTool(App&):ctx.stepFramefromopStepFor({app.threaded(), app.paused, app.debug.halted(), app.debug.attached(), /*inFrame*/ !app.threaded() && app.host.inFrame(), app.host.isLoaded(), app.netplay.configured()}):Advance->[&app]{ app.debug.requestRefresh(); app.emu.advance(1); }(the one-shot serial bump is what makes the armed snapshot refill after the frame);Inline-> the existingrunFrame()+video.upload(host.frame())lambda withapp.debug.requestRefresh()first andapp.debug.wakeViews()after (inline it runs the copier at once);None-> empty. The threaded branch calls no owner-onlyCoreHostmethod. -
Step 4: Register and run, expect PASS.
Add op_ui.cpp to VJ_NO_IFACE_FILES (not op_view.cpp: the render and walk jobs it posts call the iface inside the job; the quiet/offCore tests above pin the model). Replace src/debug/debug_session.cpp in the vj_debug_op_test / vj_debug_op_e2e source lists by ${VJ_DEBUG_SESSION_SRCS} if A3 left it.
cmake --build build -j4 --target vj_debug_op_test vj_debug_op_e2e virtualjaguar && ctest --test-dir build -R 'debug_op|debug_no_direct_iface' --output-on-failure
Expected: debug_op_unit (old lifecycle, fallback walk, paint, hardcore checks unchanged, plus the five new tests), debug_op_e2e, debug_no_direct_iface pass.
- Step 5: Commit.
git add app/src/debug/op_view.h app/src/debug/op_view.cpp app/src/debug/op_render.h app/src/debug/op_render.cpp app/src/debug/op_ui.cpp app/tests/debug_op_test.cpp app/cmake/VJDebugTests.cmake
git commit -m "feat(app): Object list reads the OpSnapshot request; step-a-frame via advance(1) (#967)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task A7: Blitter log and Textures
Files:
- Create:
app/src/debug/blitter_feed.h,app/src/debug/blitter_feed.cpp,app/tests/debug_blitter_feed_test.cpp,app/tests/debug_blitter_ui_smoke.cpp - Modify:
app/src/debug/blitter_log.h(LogTracker96-109),app/src/debug/blitter_log.cpp(aftersync194-216),app/src/debug/blitter_ui.h,app/src/debug/blitter_ui.cpp(State21-34,setLog38-55,drawLiveRegs105-134,blitterToolDraw197-249),app/src/debug/texture_pack.h(ReplaceOutcome/replaceTile151-165),app/src/debug/texture_pack.cpp(replaceTile513-534),app/src/debug/texture_ui.cpp(handlePending282-300, "Reload packs" 458-466),app/tests/debug_blitter_test.cpp,app/tests/debug_texture_test.cpp,app/CMakeLists.txt(app sources 340-357),app/cmake/VJDebugTests.cmake
Interfaces:
- Consumes:
ViewRequest::Kind::{BlitLog, Memory},ViewBuffer::{blits, blitHead, blitLost, req, ok, serial, bytes},DebugSession::{views, wakeViews, callCore, blitLogEnable, texReplaceReload},vj_debug_blit::seq(the launch number = the core's ring index, src/debug/vjdebug.c). - Produces:
// blitter_log.h
size_t LogTracker::absorb(const ViewBuffer& b); // rows added. Ignores a buffer that does not answer `since == total()`; never adds seq < total().
// blitter_feed.h
namespace vj::blit {
class LogFeed { // everything the blitter window reads, published buffers only
public:
LogTracker tracker;
void setLog(DebugSession& s, bool want); // one callCore on a change; a new epoch forgets "on" without calling
void update(DebugSession& s, bool frozen); // sets BlitLog{since = tracker.total()} + Memory{kRegBase, kRegBytes}; absorbs; wakeViews
void clear(DebugSession& s); // tracker.reset() + ONE callCore { enable(false); enable(true) }
void release(DebugSession& s);
bool logOn() const; bool haveRegs() const; const std::vector<uint8_t>& regImage() const;
};
}
// blitter_ui.h
namespace blitter_test { unsigned launchesSeen(); bool logOn(); }
// texture_pack.h
using ReloadFn = std::function<int()>; // 1 active, 0 inactive, <0 not supported (DebugSession::texReplaceReload)
ReplaceOutcome replaceTile(const OptionIO& io, const ReloadFn& reload, const std::string& systemDir, uint32_t crc, const std::string& hash,
unsigned tileW, unsigned tileH, unsigned tileBpp, const std::string& srcPng);
// the existing iface overload stays, a thin wrapper: reload = [iface]{ size check; iface->texreplace_reload() }
struct ReloadText { std::string text; bool error = false; };
ReloadText reloadText(int r); // 1 "Packs reloaded."; 0 "Packs reloaded; replacement is off or the folder is empty."; <0 error "This core cannot reload packs; restart the game."
- Step 1: Write the failing tests.
app/tests/debug_blitter_test.cpp (it already links only blitter_log.cpp + audio_ring.cpp; add #include "debug_views.h" and call from main):
static ViewBuffer blitBuf(unsigned since, unsigned head, unsigned lost, unsigned firstSeq, unsigned n) {
ViewBuffer b; b.ok = true; b.serial = 1;
b.req.kind = ViewRequest::Kind::BlitLog; b.req.since = since;
b.blitHead = head; b.blitLost = lost;
for (unsigned i = 0; i < n; i++) { vj_debug_blit x{}; x.seq = firstSeq + i; x.cmd = 0x100 + firstSeq + i; b.blits.push_back(x); }
return b;
}
static void testTrackerAbsorb() {
blit::LogTracker t;
ViewBuffer a = blitBuf(0, 5, 0, 0, 5);
CHECK(t.absorb(a) == 5 && t.total() == 5 && t.lost() == 0 && t.rows().back().seq == 4);
CHECK(t.absorb(a) == 0 && t.rows().size() == 5); // the same buffer twice: no duplicates
ViewBuffer stale = blitBuf(0, 7, 0, 0, 7); // filled for an older `since` after we moved on
CHECK(t.absorb(stale) == 0 && t.total() == 5);
CHECK(t.absorb(blitBuf(5, 2000, 971, 976, 1024)) == 1024); // the ring wrapped: launches 5..975 are gone
CHECK(t.lost() == 971 && t.total() == 2000 && t.rows().back().seq == 1999);
CHECK(t.absorb(blitBuf(2000, 3, 0, 0, 3)) == 0 && t.total() == 0 && t.rows().empty()); // head < since: the log restarted
blit::LogTracker small(4);
small.absorb(blitBuf(0, 9, 0, 0, 9));
CHECK(small.rows().size() == 4 && small.rows().front().seq == 5);
ViewBuffer bad = blitBuf(0, 5, 0, 0, 5); bad.ok = false;
blit::LogTracker u;
CHECK(u.absorb(bad) == 0 && u.total() == 0);
}
app/tests/debug_blitter_feed_test.cpp (new; UiFixture; sources blitter_feed.cpp blitter_log.cpp, ${VJ_DEBUG_SESSION_SRCS}, LABELS emu_thread):
UiFixture f;
blit::LogFeed feed;
feed.setLog(f.s, true);
CHECK(feed.logOn() && f.mock.blitLogOn && f.coreCalls == 1);
feed.setLog(f.s, true);
CHECK(f.coreCalls == 1); // no change, no call
feed.update(f.s, false); feed.update(f.s, false); // the first absorbs (inline fill), the second re-requests since=5
CHECK(feed.tracker.total() == 5 && feed.tracker.rows().size() == 5 && feed.haveRegs());
f.mock.addBlit(0x01800001u, 0x00010002);
f.s.requestRefresh();
feed.update(f.s, false);
CHECK(feed.tracker.total() == 6 && feed.tracker.rows().back().seq == 5); // 6 rows, no duplicates
f.mock.addBlit(0x01800009u, 0x00010003); f.s.requestRefresh();
feed.update(f.s, true); // frozen: requests but does not absorb
CHECK(feed.tracker.total() == 6);
feed.update(f.s, false); feed.update(f.s, false); // unfrozen: catches up with launch 6, then re-requests
CHECK(feed.tracker.total() == 7 && feed.tracker.rows().back().seq == 6);
const UiFixture::Mark m = f.mark();
for (int i = 0; i < 30; i++) feed.update(f.s, false);
CHECK(f.quietSince(m) && CountingIface::offCore() == 0);
const unsigned k = f.coreCalls;
feed.clear(f.s);
CHECK(f.coreCalls == k + 1 && feed.tracker.total() == 0 && f.mock.blitLogOn); // one wait: off then on
f.mock.blitLogOn = false; // a new core: its log starts off
f.s.setIface(f.counted.iface()); // (epoch bump, R3)
feed.setLog(f.s, true);
CHECK(f.mock.blitLogOn && feed.logOn()); // re-enabled on the new core
feed.setLog(f.s, false);
CHECK(!feed.logOn() && !f.mock.blitLogOn);
app/tests/debug_blitter_ui_smoke.cpp (new, via vj_add_debug_ui_smoke(vj_debug_blitter_ui_smoke debug_blitter_ui_smoke tests/debug_blitter_ui_smoke.cpp src/debug/blitter_ui.cpp src/debug/blitter_feed.cpp src/debug/blitter_log.cpp src/debug/audio_ring.cpp)):
blitterToolInit(fx.s); fx.s.findTool("blitter")->toggle();
settle(6);
CHECK(blitter_test::logOn() && fx.mock.blitLogOn && blitter_test::launchesSeen() == 5);
const UiFixture::Mark quiet = fx.mark();
for (int i = 0; i < 30; i++) frame();
CHECK(fx.quietSince(quiet) && CountingIface::offCore() == 0);
fx.s.findTool("blitter")->toggle(); settle(3); // closing switches the core's log off
CHECK(!fx.mock.blitLogOn);
app/tests/debug_texture_test.cpp additions (the five existing replaceTile(io, mock.iface(), ...) calls stay untouched and must still pass through the wrapper):
static void testReplaceWithCallable() {
std::string root = tmpRoot(), src = root + "/art.png";
writeText(src, pngHeader(16, 8) + "payload");
FakeOptions fo; tex::OptionIO io = fo.io();
int calls = 0, ret = 1;
tex::ReloadFn reload = [&] { calls++; return ret; };
tex::ReplaceOutcome o = tex::replaceTile(io, reload, root, 0x0e4ae2eb, kH1, 16, 8, 16, src);
CHECK(o.ok && o.reloadCalled && o.reloadActive && calls == 1);
ret = -1; // an older core: installs, says so, option still set
o = tex::replaceTile(io, reload, root, 0x0e4ae2eb, kH3, 16, 8, 16, src);
CHECK(o.ok && !o.reloadCalled && o.message.find("restart") != std::string::npos && calls == 2);
writeText(src, pngHeader(20, 8));
calls = 0;
CHECK(!tex::replaceTile(io, reload, root, 0x0e4ae2eb, kH2, 16, 8, 16, src).ok && calls == 0);
CHECK(tex::replaceTile(io, tex::ReloadFn(), root, 0x0e4ae2eb, kH3, 16, 8, 16, src).reloadCalled == false);
CHECK(tex::reloadText(1).text == "Packs reloaded." && !tex::reloadText(1).error);
CHECK(tex::reloadText(0).text.find("folder is empty") != std::string::npos && !tex::reloadText(0).error);
CHECK(tex::reloadText(-1).error);
}
- Step 2: Run, expect FAIL.
cmake --build build -j4 --target vj_debug_blitter_test vj_debug_texture_test vj_debug_blitter_feed_test vj_debug_blitter_ui_smoke
Expected: compile errors (absorb, LogFeed, ReloadFn, reloadText, blitter_test::).
- Step 3: Implement.
blitter_log.cpp LogTracker::absorb(const ViewBuffer& b): if (!b.ok || b.req.since != next_) return 0; (not an answer to the request we are on); if (b.blitHead < next_) { reset(); return 0; } (the log restarted); then lost_ += b.blitLost; push every blit with seq >= next_; next_ = b.blitHead; trim to max_; return the rows added. sync(iface) stays for debug_blitter_unit.
blitter_feed.cpp (LogFeed): ids logId_, regId_, lastSerial_, lastSince_; epochSeen_ replaces logIface. setLog(s, want): when logOn_ && epochSeen_ != s.views().epoch() forget (logOn_ = false; tracker.reset()), then on want && !logOn_ -> s.blitLogEnable(true); logOn_ = true; epochSeen_ = epoch; tracker.reset(); on !want && logOn_ -> s.blitLogEnable(false). update: s.views().set(logId_, {BlitLog, since = tracker.total()}) only when since changed, set the register request once, s.wakeViews(), read the log buffer and absorb it when !frozen && b.serial != lastSerial_, read the registers buffer (req.addr == kRegBase, ok, bytes.size() == kRegBytes). clear: tracker.reset() then one s.callCore([&]{ s.core().blitLogEnable(false); s.core().blitLogEnable(true); }). release clears both ids.
blitter_ui.cpp: State keeps session, open, frozen, filter, selected and one blit::LogFeed feed; setLog(want) is g.feed.setLog(*g.session, want); the draw path replaces the serial-gated tracker.sync(f) + read(kRegBase...) block with g.feed.update(*g.session, g.frozen); Clear calls g.feed.clear(...); the forced-close hook calls g.feed.setLog(session, false) and release. Closed or !enabled() calls release. blitter_test::{launchesSeen, logOn} read g.feed.
texture_pack.cpp: replaceTile(io, const ReloadFn& reload, ...) is the current body with if (reload) { int r = reload(); if (r >= 0) { o.reloadActive = r != 0; o.reloadCalled = true; } }; the iface overload builds the lambda with the existing size >= offsetof(...) + sizeof check (returns -1 when unsupported). reloadText as specified.
texture_ui.cpp: handlePending passes [s = g.session]{ return s->texReplaceReload(); }; the "Reload packs" button becomes ReloadText t = tex::reloadText(g.session->texReplaceReload()); setStatus(t.text, t.error);.
- Step 4: Register and run, expect PASS.
CMakeLists.txt: add src/debug/blitter_feed.cpp after blitter_log.cpp. VJDebugTests.cmake: the two new executables as above (debug_blitter_feed_unit, LABELS emu_thread, FAIL_REGULAR_EXPRESSION "FAIL "); add blitter_ui.cpp blitter_feed.cpp texture_ui.cpp to VJ_NO_IFACE_FILES.
cmake --build build -j4 --target vj_debug_blitter_test vj_debug_texture_test vj_debug_blitter_feed_test vj_debug_blitter_ui_smoke virtualjaguar && ctest --test-dir build -R 'debug_blitter|debug_texture|debug_no_direct_iface' --output-on-failure
Expected: all pass; debug_blitter_unit and debug_texture_unit keep every old check.
- Step 5: Commit.
git add app/src/debug/blitter_feed.h app/src/debug/blitter_feed.cpp app/src/debug/blitter_log.h app/src/debug/blitter_log.cpp app/src/debug/blitter_ui.h app/src/debug/blitter_ui.cpp app/src/debug/texture_pack.h app/src/debug/texture_pack.cpp app/src/debug/texture_ui.cpp app/tests/debug_blitter_test.cpp app/tests/debug_blitter_feed_test.cpp app/tests/debug_blitter_ui_smoke.cpp app/tests/debug_texture_test.cpp app/CMakeLists.txt app/cmake/VJDebugTests.cmake
git commit -m "feat(app): Blitter log and Textures through view buffers and core calls (#967)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task A8: Cheat scanner on the Dram request; Audio scope ungated
Files:
- Create:
app/tests/debug_scope_gate_test.cpp - Modify:
app/src/debug/scanner_ui.h(test hooks),app/src/debug/scanner_ui.cpp(ScannerState38-69,ramNow84-99,forceClose115-119,doScan130-166,refreshRows168-183,drawScanner450-473),app/tests/debug_scan_ui_smoke.cpp,app/cmake/VJDebugTests.cmake - No change:
app/src/debug/scanner_actions.cpp(scanPokealready goes throughDebugSession::poke, acallCoreafter A3) andapp/src/debug/scope_ui.cpp(it has no gate of its own: the gate wasDebugSession::enabled()throughtoolAvailable).
Interfaces:
-
Consumes:
ViewRequest::Kind::Dram,ViewBuffer::{bytes, ok, serial},DebugSession::{views, wakeViews, snapshotDram, poke, toolAvailable, setHaltAvailable}. -
Produces: the scanner keeps
drawScanner(DebugSession&, CheatManager&, CoreHost&)andscanner_test::scan(DebugSession&, CoreHost&)unchanged (theCoreHost&is no longer read); new hooknamespace scanner_test { bool liveValue(size_t row, int64_t& v); }. -
Step 1: Write the failing tests.
app/tests/debug_scan_ui_smoke.cpp: replace MockDebugIface mock; DebugSession session; with UiFixture fx; MockDebugIface& mock = fx.mock; DebugSession& session = fx.s; (the later session.setIface(mock.iface()) becomes session.setIface(fx.counted.iface())); keep sections as they are and add after the Equal narrowing section (the one that leaves a result list on screen):
// #967 3a: the scan pass reads RAM as of now (one callCore); the live column comes from the Dram request, and idle
// frames reach the core not at all.
const UiFixture::Mark quiet = fx.mark();
frames(session, cm, 40); // the 10 Hz live gate passes several times at 1/60 s a frame
CHECK(fx.quietSince(quiet) && CountingIface::offCore() == 0);
int64_t live = 0;
CHECK(scanner_test::liveValue(0, live)); // row 0 is the word at $000100
mock.dram()[0x101] = 0x7E; // RAM moves behind the window
session.requestRefresh(); // the next fill carries it; the gate re-reads within 0.1 s
frames(session, cm, 10);
int64_t after = 0;
CHECK(scanner_test::liveValue(0, after) && after == (int64_t)((mock.dram()[0x100] << 8) | 0x7E) && after != live);
CHECK(CountingIface::offCore() == 0);
Insert this block right after the CHECK(scanner_test::shownRows() == 3); of the "Changed keeps those" section and before the Enter-commit edit of $000100; also insert CountingIface::reset(); at the top of main.
app/tests/debug_scope_gate_test.cpp (new; vj_add_debug_ui_smoke(vj_debug_scope_gate_test debug_scope_gate tests/debug_scope_gate_test.cpp src/debug/scope_ui.cpp src/debug/audio_ring.cpp src/core_host.cpp) plus target_link_libraries(vj_debug_scope_gate_test PRIVATE vj_core Threads::Threads), as vj_debug_scan_ui_smoke links the core; frame() is the test's own NewFrame / scopeToolDraw() / Render helper):
UiFixture fx;
scopeToolInit(fx.s, CoreHost::instance());
DebugTool* t = fx.s.findTool("scope");
fx.s.setHaltAvailable(false); // the threaded 3a state
CHECK(fx.s.enabled() && t && t->toggle && fx.s.toolAvailable(*t));
t->toggle(); // open: the tap now fills the ring
std::atomic<bool> stop{false};
std::thread audio([&] { // what the emulation thread does through host.onAudio
int16_t block[128 * 2] = {};
for (int i = 0; i < 200 && !stop; i++) { block[0] = (int16_t)i; CoreHost::instance().onAudio(block, 128); }
});
for (int i = 0; i < 60; i++) frame(); // the window draws while blocks arrive
audio.join();
frame();
CHECK(scope_test::framesSeen() >= 128 * 200);
CHECK(CountingIface::offCore() == 0);
Add namespace scope_test { size_t framesSeen(); } to scope_ui.h as a pure read of g.ring.totalFrames() (the only change to scope_ui). Label the test emu_thread.
- Step 2: Run, expect FAIL.
cmake --build build -j4 --target vj_debug_scan_ui_smoke vj_debug_scope_gate_test
Expected: compile errors (scanner_test::liveValue, scope_test::framesSeen); once present, the scanner smoke fails the offCore() == 0 checks (memoryRegion and snapshotDram run on the UI thread in refreshRows).
- Step 3: Implement.
scanner_ui.cpp:
- Split
ramNow:scanRam(session, fb)returnsfb.data()aftersession.snapshotDram(fb). That is aDebugSession::read, i.e. onecallCore->EmuThread::debugCall: the 2 MBpeekruns on the emulation thread, between frames (or inside the halt pump), so a scan pass sees RAM as of one instant and the UI waits at most one frame plus the copy. It is never a UI-thread peek. The rawmemoryRegionpointer path is deleted (owner-only, counted as a violation threaded), sodebug_scan_e2e's "narrow right afterrunFrame" holds.liveRam(session)readsg.dramIdinto the persistentViewBuffer g.dramwithviews().readand returnsg.dram.bytes.data()whenok && bytes.size() == DebugSession::kDramSize, else null. doScanusesscanRam.refreshRowsusesliveRam, and reads the buffer only when its existing gate passes (lastLiveinterval, serial change,rowsDirty): a read copies 2 MB under the views mutex.ScannerStategetsDebugViews::Id dramId.drawScannersets{Dram}on the first draw while open and enabled, callssession.wakeViews()after it, and clears the id when closed,!enabled(), or inforceClose.CoreHost& hoststays in the signatures and is no longer used ([[maybe_unused]]);<core_host.h>andRETRO_MEMORY_SYSTEM_RAMleave the file.scanner_test::liveValue(row, v):g.cur[row]when it exists.
scope_ui: only scope_test::framesSeen(); nothing else changes in the window (its AudioRing is mutex-protected and g.open is atomic, which debug_blitter_unit's two-thread ring test already pins).
- Step 4: Register and run, expect PASS.
VJ_NO_IFACE_FILES gains scanner_ui.cpp scanner_actions.cpp scope_ui.cpp.
cmake --build build -j4 --target vj_debug_scan_ui_smoke vj_debug_scan_test vj_debug_scan_e2e vj_debug_scope_gate_test virtualjaguar && ctest --test-dir build -R 'debug_scan|debug_scope|debug_no_direct_iface|debug_blitter_unit' --output-on-failure
Expected: debug_scan_unit, debug_scan_e2e, debug_scan_ui_smoke, debug_scope_gate, debug_no_direct_iface pass. Finish with the 3a window set: ctest --test-dir build -R 'debug_' --output-on-failure all green and ctest --test-dir build -L emu_thread shows the new debug_core_job_unit, debug_blitter_feed_unit, debug_*_ui_smoke, debug_scope_gate, debug_no_direct_iface.
- Step 5: Commit.
git add app/src/debug/scanner_ui.h app/src/debug/scanner_ui.cpp app/src/debug/scope_ui.h app/src/debug/scope_ui.cpp app/tests/debug_scan_ui_smoke.cpp app/tests/debug_scope_gate_test.cpp app/cmake/VJDebugTests.cmake
git commit -m "feat(app): Cheat scanner on the Dram request; Audio scope available with the thread on (#967)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task A9: App wiring, the limits narrowed, the threaded test, docs, the PR
Files:
- Modify:
app/src/app.h(:335-336, :358-364),app/src/app.cpp(wireHost:463-512,syncDebug:522-526,limitFeaturesForEmuThread:533-544,liftFeatureLimits:547-554,emuRunOneFrametail :677,haltedFrame:915, F9 :1654-1657,stepEmulation:1811-1860,run():2021) - Modify:
app/src/desktop.cpp(:149-152),app/src/debug/debug_ui.cpp(:56-66),app/src/ui.cpp(:262-266) - Modify:
app/src/debug/debug_session.h/.cpp(removeiface(),setThreadBlocked,threadBlocked,threadBlocked_) - Create:
app/tests/debug_views_threaded.cpp - Test/Modify:
app/tests/debug_session_test.cpp(testThreadBlockedat :410-420),app/tests/debug_e2e.cpp(:71, :128),app/tests/debug_cpu_e2e.cpp(:95, :125, :260, :316, :395, :396),app/tests/debug_op_e2e.cpp(:114),app/cmake/VJEmuThreadTests.cmake(:6-24 header, :136-143, :194-198, new target) - Docs:
docs/app-debug-tools.md,docs/agent/testing.md,app/README.md(:548-552)
Interfaces:
-
Consumes: everything from A1-A8.
-
Produces:
void App::fillDebugViews()(debug.fillViews()), the wiring below,emu_thread_limits/emu_thread_start_fallbackwith the new messages, testdebug_views_threaded. -
Step 1: Write the failing tests.
(a) app/cmake/VJEmuThreadTests.cmake: change the two regexes (the message wording is fixed here):
# emu_thread_start_fallback (:142)
PASS_REGULAR_EXPRESSION "the debugger halt and the GDB stub are back on .the emulation thread did not start."
# emu_thread_limits (:197)
PASS_REGULAR_EXPRESSION "the debugger halt and the GDB stub are off while the emulation thread is on \\(the Tools windows work\\)"
and the header list line *_threaded limits becomes emu_thread_limits the halt and the GDB stub are off while threaded; the Tools windows work. gdb_locked_threaded is unchanged. Update the comment at :137 the same way.
(b) app/tests/debug_session_test.cpp: delete testThreadBlocked (and its call in main); the testHaltAvailable of Task A3 replaces it.
(c) Create app/tests/debug_views_threaded.cpp: the REAL core on a threaded EmuThread, the session wired exactly as App wires it. Skeleton (the case bodies follow the assertions listed):
// The debug views with the REAL core on the emulation thread (#967 phase 3a): what App does for the Tools windows,
// minus the pixels. usage: vj_debug_views_threaded <rom-a.j64> <rom-b.j64>
#include "core_host.h"
#include "debug/debug_session.h"
#include "emu_thread.h"
...
struct Rig : EmuThread::Client {
CoreHost& host = CoreHost::instance();
DebugSession dbg;
EmuThread emu;
void threadStarted() override { host.setOwner(std::this_thread::get_id()); }
void threadStopping() override { host.setOwner(std::thread::id()); }
bool canRun() override { return host.isLoaded(); }
double framePeriod() override { return 1.0 / 600.0; }
double audioQueuedMs() override { return -1.0; }
void runFrame() override { host.runFrame(); host.publishSnapshot(); dbg.fillViews(); } // emuRunOneFrame's tail
void afterCommands() override { host.publishSnapshot(); }
void wire() { // = App::wireHost + startEmuThread
host.onDebugIface = [this](const vj_debug_iface* i) { dbg.onCoreIface(i); if (!host.threaded()) dbg.syncCore(); };
dbg.coreCall = [this](std::function<void()> f) { emu.debugCall(std::move(f)); };
dbg.corePost = [this](std::function<void()> f) { emu.debugPost(std::move(f)); };
host.post = [this](std::function<void()> f) { emu.post(std::move(f)); };
}
};
Use the quoted titles below, verbatim, as the CHECK messages (they are the names the plan's Review Focus refers to). Cases, each a function with these CHECKs (threaded, emu.setPaused(true) so frames run only through advance(); host.ownerViolations() read at the end of the run must be 0):
A shared helper waitRead(id, buf) is the UI's per-frame loop: call dbg.wakeViews() every 2 ms until views().read(id, buf) succeeds, up to 500 ms; it returns whether it did. Every case fills through it, never through a running frame: the fill is once per serial, so a frame run after the fill would make the buffer and a later debugCall peek differ (yarc rewrites DRAM every frame). The thread stays paused, so nothing moves between the fill and the comparison.
- "memory, dram and regs requests equal a debugCall peek at the same frame": load
argv[1]withemu.call;dbg.setContentLoaded(true); dbg.syncCore();;emu.advance(3); set a Memory request (addr 0x1000, len 256), a Dram request, a Regs (M68K) request;dbg.requestRefresh(); for each idwaitReadsucceeds withok,frameequalsemu.debugCall([&]{ return dbg.core().frameNumber(); }), andbytesequals adebugCallpeek of the same range (the Dram buffer is hashed against a peek of 2 MiB),regs.pcequalsdebugCallgetRegs(M68K).pc. - "a poke through the session shows in the next fill":
dbg.pokeU(0x1F0000, 4, 0xCAFEF00D) == 4(marshalled throughdebugCall; the poke bumps the serial), a Memory request at0x1F0000length 4,waitRead:bytes == {CA FE F0 0D}. Noadvance(). - "a new request is readable while paused, with no advance()": record
emu.framesRun(), set aViewRequestat0x2000on the test thread,waitRead:CHECK(ok, "readable within 500 ms with no frame run")andemu.framesRun()equals the recorded value. Then change the request to0x3000(a scroll while paused) andwaitReadagain: the new range, same serial. - "after a reload the Memory window's first read is the new ROM's bytes": Memory request
0x800000length 65536 (the cartridge).emu.advance(2), copy the buffer asromA.emu.call(loadContent(argv[2]))(onCoreIfacefires on the emulation thread twice: null, new), thendbg.syncCore(). ImmediatelyCHECK(!views().read(id, b) || b.bytes != romA, "never the previous game's bytes"); thenwaitRead(the epoch bump makes the old buffer pending at the same serial);CHECK(b.bytes == (debugCall peek of 0x800000, 65536))andCHECK(b.bytes != romA)(if this fails the two ROMs share 64 KiB: pick different ROMs, do not weaken it). - "inline and threaded give the same Dram view": a helper
uint32_t dramHash(bool threaded)that builds a freshRig(its ownEmuThreadandDebugSession;coreCallcaptures that rig'semu, and a stoppedEmuThreadis not restartable), wires it, loadsargv[1], sets a Dram request,emu.advance(60),requestRefresh(),waitRead, FNV of the buffer, thenemu.call(unloadContent)andemu.stop()assession()inemu_thread_e2e.cppdoes. Inline isMode::InlinewithcorePostunset (the helper'swakeViews()then fills directly andadvancerunsinlineStep).CHECK(dramHash(false) == dramHash(true), "inline and threaded give the same Dram view"). Cases 1-4, 6 and 7 share one threadedRig; the owner-violation count is checked once at the end of each rig. - "no halt is possible while it is gated":
dbg.setHaltAvailable(false);dbg.breakNow();emu.advance(5);CHECK(!dbg.attached() && !dbg.halted()). - "hardcore closes the tools and detaches threaded":
dbg.setHardcore(true);CHECK(!dbg.enabled());dbg.setHardcore(false).
CMake (in VJEmuThreadTests.cmake, after emu_thread_e2e, also add a header-list line debug_views_threaded the Tools windows' data paths with the REAL core on the emulation thread):
add_executable(vj_debug_views_threaded tests/debug_views_threaded.cpp ${VJ_DEBUG_SESSION_SRCS} src/core_host.cpp
src/emu_thread.cpp src/paths.cpp)
target_include_directories(vj_debug_views_threaded PRIVATE src)
target_link_libraries(vj_debug_views_threaded PRIVATE vj_core ${VJ_SDL3_TARGET} Threads::Threads)
if(WIN32)
target_link_libraries(vj_debug_views_threaded PRIVATE ws2_32 iphlpapi)
endif()
vj_emu_thread_warnings(vj_debug_views_threaded)
add_test(NAME debug_views_threaded
COMMAND vj_debug_views_threaded "${VJ_ROMS}/yarc.j64" "${VJ_ROMS}/jagniccc.j64")
set_tests_properties(debug_views_threaded PROPERTIES TIMEOUT 300 LABELS emu_thread
FAIL_REGULAR_EXPRESSION "FAIL |owner violation|ThreadSanitizer")
- Step 2: Run to see what fails.
Run: cmake --build build -j4 && ctest --test-dir build -R "emu_thread_limits|emu_thread_start_fallback|debug_views_threaded" --output-on-failure
Expected: emu_thread_limits and emu_thread_start_fallback FAIL (the log still says "the debugger tools and the GDB stub ..."). debug_views_threaded should PASS already: A1-A3 supply every API it uses (fillViews, wakeViews, onCoreIface, the lanes), and it fills through wakeViews() exactly as App::syncDebug will. It guards the paths the steps below wire the App to, under ThreadSanitizer. If a case fails, read it as a plumbing bug in A1-A3 first; the cases never rely on a frame running between a fill and a comparison.
-
Step 3: Wire the App.
-
app.h: declarevoid fillDebugViews();(comment: emulation thread, inline the UI thread;debug.fillViews()).app.cpp: define it. Call it (a) at the end ofemuRunOneFrame()afterhost.publishSnapshot()(:677); (b) instepEmulation()once after thefor (frames)loop, not inside it, so fast-forward does not fill four times per UI frame.haltedFrame()needs no separate call: it already runssyncDebug(), andwakeViews()below fills inline there (record this in the comment). -
wireHost()(:498): replacehost.onDebugIfacewith[this](const vj_debug_iface* i) { debug.onCoreIface(i); if (!host.threaded()) debug.syncCore(); }, and adddebug.coreCall = [this](std::function<void()> f) { emu.debugCall(std::move(f)); };anddebug.corePost = [this](std::function<void()> f) { emu.debugPost(std::move(f)); };(beforeEmuThread::startthe thread is inline, so both run at once; headless and inline runs are unchanged). -
syncDebug()(:522): first linedebug.syncCore();, last linedebug.wakeViews();(aftertick). -
Limits:
limitFeaturesForEmuThread()becomesdebug.setHaltAvailable(false);plus the existing option lock forvirtualjaguar_gdb_stub; deletedebug.setThreadBlocked(true)andhost.announceDebug = false(the core must hand its interface over; it cannot halt becauseattach()refuses and the stub is locked). Log:[emu] the debugger halt and the GDB stub are off while the emulation thread is on (the Tools windows work).liftFeatureLimits():debug.setHaltAvailable(true);(noannounceDebugline), log[emu] the debugger halt and the GDB stub are back on (the emulation thread did not start). Update the comment abovelimitFeaturesForEmuThread(:528-532) andapp.h:335. -
run():2021:else if (openDebugWindow(debug, a.showWindow)) {}(drop!emuThreadThisRun_ &&). -
Break/Step with the halt gated:
debug_ui.cpp:61MenuItem("Break", "F9", false, on && d.haltAvailable())with a hover tooltipd.haltDisabledReason()when greyed for that reason;app.cppF9 (:1655-1656) anddesktop.cppdebugBreakContinue()(:149-152):else if (debug.enabled()) { if (debug.haltAvailable()) debug.breakNow(); else toast(debug.haltDisabledReason(), 3.0); }(desktop.cpp:a.toast(...)). The gate itself is insidebreakNow()/attach(); this is only the explanation. -
ui.cpp:262-266hint: replace the sentence "While it is on, this version turns off the Tools (debugger) menu and the GDB stub; they come back in a later update." with "While it is on, Break, Step and breakpoints and the GDB stub are off for now; the Tools windows work." -
Remove
DebugSession::iface(),setThreadBlocked(),threadBlocked()andthreadBlocked_(and thethreadBlocked_term inenabled()and the first line ofdisabledReason()). Switch the tests that calleddbg.iface()todbg.core().iface()(the files and lines under Files). Then prove nothing is left:Run:
/usr/bin/grep -rn "\.iface()\|->iface()\|setThreadBlocked\|threadBlocked" app/src app/tests | /usr/bin/grep -v "mock\.iface()\|m\.iface()\|m[0-9]*\.iface()\|\.core()\.iface()\|core_\.iface()\|c\.iface()\|big\.iface()\|small\.iface()"Expected: no output.
-
-
Step 4: Run the whole suite.
Run: cmake --build build -j4 && ctest --test-dir build -j4 --output-on-failure
Expected: all PASS, including emu_thread_limits, emu_thread_start_fallback, gdb_locked_threaded (unchanged), debug_views_threaded, and every inline debug_* and smoke_* test.
Then the sanitizer pass (CI runs it on Linux; locally it is optional on macOS, run it when uptime shows a load under 4):
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build-tsan -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF
cmake --build build-tsan -j4
TSAN_OPTIONS="halt_on_error=1 abort_on_error=0 second_deadlock_stack=1 suppressions=app/tests/tsan.supp" ctest --test-dir build-tsan -C RelWithDebInfo -L emu_thread --output-on-failure
Expected: PASS, no report. app/tests/tsan.supp is unchanged: a report is fixed by publishing the value (an atomic or a view buffer), never by a suppression.
-
Step 5: Docs.
app/README.md## Emulation thread(:548-552): replace "While it is on, this version switches off the Tools (debugger) windows and the GDB stub option; they move to the emulation thread in the next phase." with "While it is on, every Tools window works (their data is copied by the emulation thread after each frame, and their edits run on it), but Break, Step, breakpoints and the GDB stub option are off until the next phase." Keep the RetroAchievements / netplay / spinner / owner-violation sentences. Layout table: add rowssrc/debug/debug_core.*(the emulation-thread half of the debug session: the interface wrappers and, in the next phase, the halt) andsrc/debug/debug_views.*(the per-window requests the emulation thread fills).docs/app-debug-tools.md: Pieces table gainsapp/src/debug/debug_core.{h,cpp}anddebug_views.{h,cpp}; "Halt model" intro: "All calls are made on the thread that runsretro_run()" becomes "... which is the UI thread inline and the emulation thread when Settings > Interface > Emulation thread is on"; the last bullet of "How the windows plug in" ("All core calls happen on the main thread ...") becomes a new subsection Threading: periodic readers set aDebugViewsrequest and read the buffer the emulation thread filled after the last frame (stale across a content switch by epoch); one-shot actions (poke, register edit, arm, log enable, pack reload, attach) go throughEmuThread::debugCalland wait at most one frame; with the thread on, Break, Step and breakpoints are greyed ("not available ... this version") and the GDB stub stays locked. Testing section: adddebug_views_unit,debug_views_threaded.docs/agent/testing.md("App emulation thread tests (#967)"): add a Phase 3a bullet list:debug_views_unit(requests, buffers, epoch; concurrent set/fill/read under TSan), the lane cases inemu_commands_unit/emu_thread_unit,debug_views_threaded(the REAL core: views equal adebugCallpeek, a paused request fills with no frame, the first read after a reload is the new ROM, inline == threaded), and the rewordedemu_thread_limits/emu_thread_start_fallback.docs/app-emulation-thread-design.md: a dated (2026-10-20) amendment under section 5 recordingDebugViews' contract (requests keyed by id, filled byfill(iface, serial)after each frame, a changed request or a content switch invalidates its buffer) andLane::Debug.
-
Step 6: Commit
git add app/src app/tests app/cmake docs/app-debug-tools.md docs/agent/testing.md app/README.md docs/app-emulation-thread-design.md
git commit -F - <<'EOF'
feat(app): the Tools windows work with the emulation thread on (#967)
The App fills the debug views after each emulated frame (and on the UI thread
inline), wires DebugSession to the debug lane, hands the core's interface
over again and narrows the thread gate to "halt not available": Break, Step,
breakpoints and the GDB stub stay off until phase 3b. --show-window opens a
tool threaded. DebugSession::iface()/setThreadBlocked() are gone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
- Step 7: Studio checks. Windowed runs happen on the Studio only; check
uptimeon it first (bash $VJR run emu-p3a uptime) and wait while its load is above 2.
VJR=~/Workspace/Provenance/vj-eval/remote/vjr.sh
bash $VJR push emu-p3a HEAD
bash $VJR run emu-p3a env -u DEVELOPER_DIR bash -lc 'uptime; cmake -S app -B app/build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build app/build -j14 >/dev/null && ctest --test-dir app/build -C Release -E shaders_gl -j6 2>&1 | tail -5'
Expected: the full suite passes on the Studio.
Then, in one vjr.sh run (the Studio is a login-less host; VJ_WINDOWED_OK=1 is the guard the script requires):
bash $VJR run emu-p3a env -u DEVELOPER_DIR VJ_WINDOWED_OK=1 bash -lc '
out=$HOME/vj-remote/out; mkdir -p $out
bash app/tests/emu_thread_windowed_check.sh app/build/virtualjaguar test/roms/yarc.j64 $out/emu-p3a-windowed
for w in memory palette scanner objects blitter textures scope; do
app/build/virtualjaguar --emu-thread on --show-window $w --perf-frames 300 test/roms/yarc.j64 >$out/emu-p3a-$w.log 2>&1
grep -c "owner violation" $out/emu-p3a-$w.log
done
app/build/virtualjaguar --emu-thread on --show-window memory --screenshot $out/emu-p3a-memory-on.png --screenshot-frame 240 test/roms/yarc.j64 >$out/emu-p3a-memory-shot.log 2>&1
app/build/virtualjaguar --emu-thread off --show-window memory --screenshot $out/emu-p3a-memory-off.png --screenshot-frame 240 test/roms/yarc.j64 >>$out/emu-p3a-memory-shot.log 2>&1
'
bash $VJR pull emu-p3a
Expected: emu_thread_windowed_check: OK; every per-window grep count is 0; ~/Workspace/Provenance/vj-eval/remote/emu-p3a/emu-p3a-memory-on.png shows hex bytes in the Memory window (open it with the Read tool and look: not blank, not "no data"), like the -off.png.
TSan windowed pass (a free-running window is the only thing that runs the UI reading buffers against a live emulation thread): configure -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo in app/build-tsan on the Studio and run --emu-thread on --show-window memory --perf-frames 600 --perf-input test/roms/yarc.j64 with TSAN_OPTIONS="halt_on_error=1 abort_on_error=0 suppressions=app/tests/tsan.supp". Expected: no ThreadSanitizer line. Any report is fixed by routing the reader through a published value (task A2/A3 types), never a suppression.
Evenness with the Memory window open (the copier adds per-frame cost; report it, do not tune it away). A/B/B/A on yarc.j64 at 60 Hz, 3600 UI frames each, via even-run.sh (which gates on load, wakes the display and kills hangs):
bash $VJR run emu-p3a env -u DEVELOPER_DIR ROMS=test/roms/yarc.j64 bash -lc '
bash ~/vj-remote/even-run.sh emu-p3a-A1 latch
EXTRA="--show-window memory" bash ~/vj-remote/even-run.sh emu-p3a-B1 latch
EXTRA="--show-window memory" bash ~/vj-remote/even-run.sh emu-p3a-B2 latch
bash ~/vj-remote/even-run.sh emu-p3a-A2 latch'
mkdir -p ~/Workspace/Provenance/vj-eval/remote/emu-p3a/even
scp -q 'studio-runner:vj-remote/out/even-emu-p3a-*' ~/Workspace/Provenance/vj-eval/remote/emu-p3a/even/
for r in A1 B1 B2 A2; do python3 app/tests/perf_trace_summary.py ~/Workspace/Provenance/vj-eval/remote/emu-p3a/even/even-emu-p3a-$r-yarc_j64-1-latch; done
(A = threaded, no window; B = threaded, Memory window open. Summarise each pair with perf_trace_summary.py: shown repeats and skips per minute, exact event -> present, and the emulation thread's frame cost, end minus start, from -emu.csv.) Expected: B within the A run-to-run spread on repeats + skips per minute, and a frame-cost increase under 0.2 ms. A larger cost or worse evenness goes into the PR body as a finding with its numbers.
- Step 8: Open the PR. Write the body to
~/Workspace/Provenance/vj-eval/remote/emu-p3a/pr-body.md, filling every<...>from Step 7.
Refs #967 (phase 3a of 5: docs/app-emulation-thread-plan-phase3.md, design: docs/app-emulation-thread-design.md)
With Settings > Interface > Emulation thread on, every Tools window now works: Memory, Palette, Cheat scanner, CPU
debugger (registers and disassembly), Object list, Blitter log, Textures and the Audio scope. Break, Step,
breakpoints and the GDB stub stay off (they are greyed with a reason; phase 3b). Still off by default in every build.
- `Lane::Debug` in the command queue and `EmuThread::debugCall` / `debugPost`: one-shot actions (poke, register
edit, arm, log enable, pack reload) run on the emulation thread.
- `DebugViews`: each window sets a request (memory range, registers, disassembly window, OP snapshot, blit-log
tail, the scanner's 2 MiB) and reads the buffer the emulation thread fills after each frame. Inline, the same
copier runs on the same thread. A changed request or a content switch invalidates its buffer.
- `DebugCore` owns the interface; `DebugSession` marshals to it. `--show-window <tool>` works threaded.
Tests: `debug_views_unit`, lane cases in `emu_commands_unit` / `emu_thread_unit`, `debug_views_threaded` (the real
core), `emu_thread_limits` rewritten; the full suite and the ThreadSanitizer label pass.
Studio (M4 Max, 60 Hz): windowed check OK; each of the seven tools opened threaded with no `owner violation`; the
Memory window shows live bytes (screenshot attached); TSan windowed pass clean. Evenness A/B/B/A on yarc with the
Memory window open: A <repeats>+<skips>/min, B <repeats>+<skips>/min; emulation frame cost A <x> ms, B <y> ms.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
git push libretro HEAD:refs/heads/feat/967-phase3a-debug-views
gh pr create --repo libretro/virtualjaguar-libretro --base develop --head feat/967-phase3a-debug-views \
--title "feat(app): debug views on the emulation thread (#967 phase 3a)" \
--body-file ~/Workspace/Provenance/vj-eval/remote/emu-p3a/pr-body.md
Expected: the PR URL. Do not merge; 3b and 3c branch after it merges. Remove the worktree (git worktree remove ~/Workspace/Provenance/vj-wt/emu-p3a) and the Studio's (bash $VJR drop emu-p3a) when the PR merges.
Sub-phase 3b: the halt and the GDB stub on the emulation thread
Branch feat/967-emu-thread-phase3b off libretro/develop after 3a merges, worktree ~/Workspace/Provenance/vj-wt/emu-p3b
(git -C ~/Workspace/Provenance/virtualjaguar-libretro fetch libretro && git worktree add ~/Workspace/Provenance/vj-wt/emu-p3b -b feat/967-emu-thread-phase3b libretro/develop).
Assumes 3a's interfaces as frozen in the header: Lane::Debug, debugCall/debugPost, DebugViews, DebugCore with the iface wrappers, DebugSession::coreCall/corePost/setHaltAvailable, App::fillDebugViews.
The PR leaves off Neuro (3c) and the nightly default (3d). The setting stays Off by default; haltedFrame() survives for inline mode.
Tasks: B1 HaltState, the tagged verdict, the bounded pump() and the UI mirror (syncHalt); B2 App threaded wiring (waitVerdict / haltWait, frameEnded, quitting_ / beginQuit, haltedFrame inline-only, "HALTED by gdb"); B3 call() and advance() give up when a halt begins, every UI call site, the call-site policy; B4 the limits lifted (halt gate + GDB lock), a silent host behind every halt, hardcore locks the stub; B5 debug_e2e_threaded, gdb_threaded_halt, the signal quit; B6 docs, Studio checks (the controller's), PR. (Rewritten 2026-10-22 to the amended brief set; decisions 2 and 3 stay proposals until the maintainer confirms them in the 3b PR.)
Build and test commands throughout: env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release once, then cmake --build build -j4 --target <t> && ctest --test-dir build -R <regex> --output-on-failure. Check uptime first; no builds while the load is above 10.
Task B1: HaltState, the verdict atomic and the bounded pump()
Files:
- Modify:
app/src/debug/debug_core.h/.cpp(A3's file: add the "the halt (B1)" block of the Interfaces section plusframeEnded()),app/src/emu_thread.h/.cpp(serveDebugLane, emu_thread.h:95-117 area and the loop at emu_thread.cpp:153-205),app/src/debug/debug_session.h/.cpp(serial_atomic;resume/step/detachAndResumewritecore().verdict;detach()while halted writes DETACH; the oldpump/queued_/halted_fields go) - Modify:
app/tests/debug_e2e.cpp(dbg.runUiFrame/dbg.onHalt/dbg.onResume/dbg.abortHaltbecomedbg.core().runUiFrame/core().onHalt/core().onResume/core().abortHalt; everyrunUiFramebody starts withdbg.syncHalt()once B2 lands it, until thenhalted()reads the core directly; the counts at :137-141 hold),app/src/debug/cpu_ui.cpp(registerCpuTools'onHalthook moves tos.core().onHaltfor this commit: halts are impossible threaded until B4, so the emulation-thread callback touchingg.tis safe here; B2 moves it toonHaltUi) - Test:
app/tests/debug_session_test.cpp(new sections againstDebugCore+ the mock),app/tests/emu_thread_test.cpp(serveDebugLane),app/cmake/VJDebugTests.cmake(vj_debug_session_testlinks${VJ_DEBUG_SESSION_SRCS}, getsLABELS emu_thread),app/tests/debug_mock.h(no change:simulateHalt(..., maxPumps, &pumps)already loops on NONE)
Interfaces:
-
Consumes:
MockDebugIface::simulateHalt(debug_mock.h:131),EmuCommands::waitUntil/drain(Lane::Debug)/pending(A1),DebugSession::coreCall/corePost(A3). -
Produces (verbatim from the header's
debug_core.hblock):struct HaltState,DebugCore::kPumpWait,halt(),haltSerial(),verdict,notifyVerdict,waitVerdict,runUiFrame,abortHalt,onHalt,onResume,int pump(int target, unsigned pc),static int haltPumpThunk(void*, int, unsigned); plus change request 1:void frameEnded();.size_t EmuThread::serveDebugLane(std::chrono::milliseconds maxWait). -
pump()contract (header, with requests 2-3 applied): new-halt test!st.halted || st.target != target || st.pc != pc; on a new halt: record target/pc,reason = iface->halt_reason()when the iface'ssizecovers it (as debug_session.cpp:129-131) elseVJ_DBG_STOP_BREAKPOINT,serial++, fireonHalt(target, pc, reason). Then:if (abortHalt && abortHalt()) v = DETACH; else v = verdict.exchange(NONE); if (v == NONE) { if (waitVerdict) waitVerdict(); else if (runUiFrame) runUiFrame(); v = verdict.exchange(NONE); if (v == NONE && abortHalt && abortHalt()) v = DETACH; }.v == NONE-> return NONE with the state untouched. Otherwisehalted = false,target = -1,reason = NONE(pc kept for the bar's last value); DETACH ->attached_ = false; CONTINUE/DETACH ->serial++, fireonResume; returnv. The mutex guardsHaltState;serialis also mirrored in anstd::atomic<uint64_t>forhaltSerial(). -
serveDebugLane(maxWait): inline (!threaded()) ->cmds_.drain(onError, Lane::Debug)and return. Threaded:n = drain(Debug); ifn == 0:cmds_.waitUntil(now + maxWait)thenn = drain(Debug); returnn. Must be called on the emulation thread (assertonEmuThread()in debug builds; a wrong-thread call returns 0). -
Step 1: Write the failing tests. In
app/tests/debug_session_test.cpp, add#include "debug_core.h"and, beforemain, these sections (the file'sCHECKmacro;MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());opens each unless said otherwise):
// B1: the pump is bounded. With nothing to say it calls waitVerdict once and returns NONE; the halt stays.
static void testBoundedPumpReturnsNone() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int waits = 0, halts = 0, resumes = 0;
c.waitVerdict = [&] { waits++; };
c.onHalt.push_back([&](int, unsigned, int) { halts++; });
c.onResume.push_back([&] { resumes++; });
const uint64_t s0 = c.haltSerial();
CHECK(c.pump(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_NONE);
CHECK(waits == 1 && halts == 1 && resumes == 0);
CHECK(c.halt().halted && c.halt().target == VJ_DBG_TARGET_M68K && c.halt().pc == 0x4000);
CHECK(c.haltSerial() == s0 + 1);
CHECK(c.pump(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_NONE); // the same halt: no second onHalt
CHECK(waits == 2 && halts == 1 && c.haltSerial() == s0 + 1);
c.verdict.store(VJ_DBG_RESUME_CONTINUE);
CHECK(c.pump(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_CONTINUE);
CHECK(waits == 2); // a pending verdict is taken before any wait
CHECK(!c.halt().halted && resumes == 1 && c.haltSerial() == s0 + 2);
CHECK(c.verdict.load() == VJ_DBG_RESUME_NONE);
}
// Review Focus 6: a detach verdict ends a bounded pump within one wait, through the mock's GDBHalt loop.
static void testDetachEndsPumpWithinOneWait() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int waits = 0;
c.waitVerdict = [&] { // what App::haltWait does when the UI writes DETACH during the wait
if (++waits == 2) c.verdict.store(VJ_DBG_RESUME_DETACH);
};
int pumps = 0;
const int v = m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_USER, 100, &pumps);
CHECK(v == VJ_DBG_RESUME_DETACH);
CHECK(pumps == 2 && waits == 2); // pump 1: wait, NONE; pump 2: the verdict written inside its wait is taken
CHECK(!c.attached() && !m.attached() && !c.halt().halted);
}
// Request 2: the serial moves on every entry (a step's re-halt too) and on CONTINUE / DETACH, not on the STEP return.
static void testStepSerialAndReason() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int halts = 0, resumes = 0, lastReason = -1;
c.onHalt.push_back([&](int, unsigned, int r) { halts++; lastReason = r; });
c.onResume.push_back([&] { resumes++; });
c.waitVerdict = [&] { c.verdict.store(VJ_DBG_RESUME_STEP); };
const uint64_t s0 = c.haltSerial();
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT) == VJ_DBG_RESUME_STEP);
CHECK(halts == 1 && resumes == 0 && c.haltSerial() == s0 + 1 && !c.halt().halted);
c.waitVerdict = [&] { c.verdict.store(VJ_DBG_RESUME_CONTINUE); };
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4002, VJ_DBG_STOP_STEP) == VJ_DBG_RESUME_CONTINUE);
CHECK(halts == 2 && lastReason == VJ_DBG_STOP_STEP && resumes == 1 && c.haltSerial() == s0 + 3);
}
// Request 1: gdb continued (or the timeout fired): GDBHalt breaks without a verdict of ours. frameEnded() ends the
// halt on our side and drops a verdict written for it meanwhile.
static void testFrameEndedClosesForeignResume() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int resumes = 0;
c.onResume.push_back([&] { resumes++; });
c.waitVerdict = [] {};
int pumps = 0;
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT, 1, &pumps) == VJ_DBG_RESUME_NONE);
CHECK(pumps == 1 && c.halt().halted);
const uint64_t s1 = c.haltSerial();
c.verdict.store(VJ_DBG_RESUME_CONTINUE); // the UI pressed F9 against a halt gdb already ended
c.frameEnded();
CHECK(!c.halt().halted && resumes == 1 && c.haltSerial() == s1 + 1);
CHECK(c.verdict.load() == VJ_DBG_RESUME_NONE);
c.frameEnded(); // not halted: a no-op
CHECK(resumes == 1 && c.haltSerial() == s1 + 1);
CHECK(c.attached()); // a foreign continue is not a detach
}
// Request 3: a gdb step re-halts at a new pc inside the same frame: a new halt (pc, onHalt, serial).
static void testForeignStepIsNewHalt() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int halts = 0;
c.onHalt.push_back([&](int, unsigned, int) { halts++; });
c.waitVerdict = [] {};
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT, 1) == VJ_DBG_RESUME_NONE);
const uint64_t s1 = c.haltSerial();
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4002, VJ_DBG_STOP_STEP, 1) == VJ_DBG_RESUME_NONE);
CHECK(halts == 2 && c.halt().pc == 0x4002 && c.halt().reason == VJ_DBG_STOP_STEP && c.haltSerial() == s1 + 1);
}
// Quit mid-halt: abortHalt wins over a pending CONTINUE and detaches the mock.
static void testAbortHaltDetaches() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
bool quitting = false;
c.abortHalt = [&] { return quitting; };
c.waitVerdict = [&] { quitting = true; c.verdict.store(VJ_DBG_RESUME_CONTINUE); };
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_DETACH);
CHECK(!c.attached() && !m.attached() && !c.halt().halted);
}
// Inline: no waitVerdict; the pump runs runUiFrame once per call and takes the verdict that frame set (today's loop).
static void testInlinePumpRunsUiFrame() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
int ui = 0;
c.runUiFrame = [&] { if (++ui == 3) c.verdict.store(VJ_DBG_RESUME_CONTINUE); };
int pumps = 0;
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT, 100, &pumps) == VJ_DBG_RESUME_CONTINUE);
CHECK(ui == 3 && pumps == 3);
}
// The pump never waits longer than waitVerdict does: ten NONE pumps with a 4 ms wait take under 100 ms.
static void testPumpNeverOutwaitsHook() {
MockDebugIface m; DebugCore c; c.setIface(m.iface()); CHECK(c.attach());
c.waitVerdict = [] { std::this_thread::sleep_for(DebugCore::kPumpWait); };
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 10; i++) CHECK(c.pump(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_NONE);
CHECK(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(100));
}
// The session's verdict methods write core().verdict and call notifyVerdict; outside a halt they do nothing.
static void testSessionVerdictsReachCore() {
DebugSession s; MockDebugIface m; s.core().setIface(m.iface()); s.setContentLoaded(true);
int notifies = 0;
s.core().notifyVerdict = [&] { notifies++; };
s.resume();
CHECK(s.core().verdict.load() == VJ_DBG_RESUME_NONE && notifies == 0);
s.core().waitVerdict = [&] { s.syncHalt(); s.step(); }; // the UI thread, one frame later
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_STEP);
CHECK(notifies == 1);
s.core().waitVerdict = [&] { s.syncHalt(); s.detach(); }; // Detach while halted = the DETACH verdict
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4002, VJ_DBG_STOP_STEP) == VJ_DBG_RESUME_DETACH);
CHECK(notifies == 2 && !m.attached());
}
(#include <chrono> / <thread> at the top.) Register all nine in main after testHaltResume. Rewrite testHaltResume and testHardcoreWhileHalted for the split: s.runUiFrame -> s.core().runUiFrame, s.onHalt/onResume -> s.core().onHalt/onResume, s.abortHalt -> s.core().abortHalt, and each runUiFrame body starts with s.syncHalt(); (the mirror is what s.halted() reads); s.resumeQueued() -> s.core().verdict.load() != VJ_DBG_RESUME_NONE. The checks keep their values (pumps == 3, halts == 2 && resumes == 1, DETACH on quit, why == "hardcore").
In app/tests/emu_thread_test.cpp, before main:
// B1: the halt pump's lane service. Threaded, it drains the debug lane, waits at most maxWait for a post, drains
// again; inline it drains and returns at once.
void testServeDebugLane() {
EmuThread e;
FakeClient c;
c.block = true; // the "core" is parked inside a frame, as it is during a halt
CHECK(e.start(EmuThread::Mode::Threaded, c), "start");
for (int i = 0; i < 1000 && !c.blocked.load(); i++) sleepMs(1);
std::atomic<int> ran{0};
std::atomic<size_t> served{0};
std::atomic<double> waited{0};
// Run serveDebugLane where the frame runs: inside the blocked frame, through a second client hook is not
// available, so call it from the emulation thread via a debug post is circular. Instead FakeClient::runFrame
// calls `onBlocked` while blocked: set it to serve the lane once.
c.onBlocked = [&] {
const auto t0 = Clock::now();
served = e.serveDebugLane(std::chrono::milliseconds(4));
waited = since(t0);
};
sleepMs(30);
CHECK(served.load() == 0, "nothing queued: 0 commands (%zu)", served.load());
CHECK(waited.load() >= 0.003 && waited.load() < 0.05, "and it waited about maxWait (%.1f ms)", waited.load() * 1e3);
c.onBlocked = [&] {
const auto t0 = Clock::now();
served = e.serveDebugLane(std::chrono::milliseconds(4));
waited = since(t0);
};
e.debugPost([&] { ran++; });
sleepMs(30);
CHECK(ran.load() == 1 && served.load() == 1, "a debug post is run by the lane service (%d, %zu)", ran.load(), served.load());
c.block = false;
CHECK(e.stop(), "stop");
EmuThread in;
FakeClient ic;
CHECK(in.start(EmuThread::Mode::Inline, ic), "inline start");
const auto t0 = Clock::now();
CHECK(in.serveDebugLane(std::chrono::milliseconds(50)) == 0 && since(t0) < 0.01, "inline: returns at once");
}
Add to FakeClient: std::function<void()> onBlocked; and in runFrame()'s while (block.load()) body, after blocked = true;: if (onBlocked) { auto f = onBlocked; onBlocked = nullptr; f(); }. Register testServeDebugLane in main.
In app/cmake/VJDebugTests.cmake: vj_debug_session_test sources gain src/debug/debug_core.cpp (if A3 did not add it) and its set_tests_properties gains LABELS emu_thread (the mirror tests run two code paths the TSan job should see).
- Step 2: Run them and watch them fail
Run: cmake --build build -j4 --target vj_debug_session_test vj_emu_thread_test 2>&1 | tail -3
Expected: compile errors (HaltState, pump, frameEnded, serveDebugLane, onBlocked).
-
Step 3: Implement.
debug_core.h/.cpp: the header's "the halt (B1)" block verbatim plusvoid frameEnded();; privatemutable std::mutex haltMu_; HaltState halt_; std::atomic<uint64_t> serial_{0};.DebugCore()keeps A3'shost_fill and setshost_.halt_pump = &haltPumpThunk.setIface()drops the halt (state cleared under the mutex, no callbacks, no serial bump) as well asattached_.pump()per the contract above;frameEnded()per request 1 (firesonResumeoutside the mutex; copies of the callback vectors are walked, as debug_session.cpp:133 does).emu_thread.h/.cpp:serveDebugLaneper the Interfaces bullet.debug_session.h/.cpp: removepump,haltPumpThunk,queued_,halted_,haltedTarget_,haltedPc_,haltReason_,runUiFrame,abortHalt,onHalt,onResume,resumeQueued(header "Removed from this class").resume()=if (halted() && core().haltSerial() == haltSerial()) { core().verdict.store(VJ_DBG_RESUME_CONTINUE); if (core().notifyVerdict) core().notifyVerdict(); },step()the same with STEP plususerHaltPending_ = true,detachAndResume()with DETACH.detach()=halted() ? detachAndResume() : corePost([this]{ core().detach(); })thenattachedMirror_ = false(the mirror ofattached()is refreshed bysyncHalt()fromcore().attached(); B2 addssyncHalt, so for B1halted()/attached()readcore().halt().halted/core().attached()directly and B2 swaps in the mirror).serial_becomesstd::atomic<uint64_t>(request 5).
-
Step 4: Run the tests
Run: cmake --build build -j4 --target vj_debug_session_test vj_emu_thread_test && ctest --test-dir build -R "debug_session_unit|emu_thread_unit" --output-on-failure
Expected: both pass; debug_session_test: N checks, 0 failed. Then ctest --test-dir build -R "debug_" --output-on-failure: every 3a debug test still green (inline unchanged).
- Step 5: Commit
git add app/src/debug/debug_core.h app/src/debug/debug_core.cpp app/src/debug/debug_session.h app/src/debug/debug_session.cpp \
app/src/emu_thread.h app/src/emu_thread.cpp app/tests/debug_session_test.cpp app/tests/emu_thread_test.cpp \
app/cmake/VJDebugTests.cmake
git commit -F - <<'EOF'
feat(app): bounded halt pump on DebugCore with an atomic verdict (#967)
DebugCore::pump serves the debug lane, waits at most kPumpWait (4 ms) and returns VJ_DBG_RESUME_NONE to GDBHalt's own
loop, which keeps the socket service and the halt timeout. The UI writes the verdict atomically; HaltState carries a
serial the UI mirrors. frameEnded() closes a halt the core left on gdb's say-so, so the app never stays "halted"
after a foreign continue. Inline keeps the nested UI frame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task B2: App wiring: waitVerdict, syncHalt, quitting_, shutdown detach, F9/F10 through the mirror
Files:
- Modify:
app/src/debug/debug_session.h/.cpp(syncHalt, the mirror fields,onHaltUi/onResumeUi,haltByGdb,haltSerial,userHaltPending_,setHardcorethrough the lane),app/src/app.h(haltWait,quitting_,quitRequested, removehaltedNow's comment about inFrame),app/src/app.cpp(wireHost497-515: hooks move todebug.core();syncDebug522-526:+ debug.syncHalt();startEmuThread592-611:debug.core().waitVerdict = [this]{ haltWait(); };liftFeatureLimits547-554: clears it;shutdown412-425 andstopEmuThread633-641: detach first;emuRunOneFrame654 andstepEmulation1833:debug.core().frameEnded()afterhost.runFrame();haltedFrame915-942: the guard becomes!imguiUp_ || !window_ || threaded(); F9 1654-1657 and F10 1700-1703 unchanged in text, they already go through the mirror),app/src/debug/debug_ui.cpp(halt bar: "HALTED by gdb" whend.haltByGdb(), the "applies after Continue" line is B3's),app/src/desktop.cpp:149-158(unchanged in text; verified to compile against the mirror) - Modify:
app/src/debug/cpu_ui.cpp(registerCpuTools: thet.prev = t.regs; s.requestRefresh()hook moves froms.core().onHalt(B1) tos.onHaltUi, the UI thread) - Test:
app/tests/debug_session_test.cpp(mirror sections)
Interfaces:
-
Consumes: B1's
DebugCore;EmuThread::serveDebugLane,debugPost;DebugViews::pending;App::fillDebugViews(A9). -
Produces: header's
DebugSessionB2 members:void syncHalt();,bool halted() const;,int haltedTarget() const; unsigned haltedPc() const; int haltReason() const;,onHaltUi,onResumeUi; request 4:bool haltByGdb() const;,uint64_t haltSerial() const;.App:void haltWait();(emulation thread:emu.serveDebugLane(DebugCore::kPumpWait); if (debug.views().pending(debug.refreshSerial())) fillDebugViews();),std::atomic<bool> quitting_{false};,bool quitRequested() const;(B2:quit; B5 adds the signal). -
syncHalt():HaltState h = core().halt(); attachedMirror_ = core().attached(); if (h.serial != mirror_.serial) { const bool was = mirror_.halted; mirror_ = h; if (h.halted) { byGdb_ = h.reason == VJ_DBG_STOP_BREAKPOINT ? !hasBreakpoint(h.target, h.pc) : !userHaltPending_; userHaltPending_ = false; requestRefresh(); fire onHaltUi(h.target, h.pc, h.reason); } else if (was) fire onResumeUi; } else mirror_ = h;. A serial that moved by two or more between two UI frames (halt and resume inside one UI frame, or step + re-halt) fires the edge for the final state only:onHaltUiif now halted,onResumeUiif it was halted and is not, nothing if neither. -
Hardcore:
setHardcore(true)=detach()(B1's: DETACH verdict when halted, else a lane post) +closeAllTools("hardcore"), unchanged in shape; it no longer touches the iface on the UI thread. -
Step 1: Write the failing tests. In
app/tests/debug_session_test.cpp, beforemain:
// B2: the UI mirror. syncHalt() fires onHaltUi once per halt serial and onResumeUi once per resume; a step's re-halt
// fires onHaltUi again with reason STEP; a halt that came and went between two UI frames fires nothing.
static void testSyncHaltEdges() {
DebugSession s; MockDebugIface m; s.core().setIface(m.iface()); s.setContentLoaded(true);
int haltUi = 0, resumeUi = 0, lastReason = -1;
s.onHaltUi.push_back([&](int, unsigned, int r) { haltUi++; lastReason = r; });
s.onResumeUi.push_back([&] { resumeUi++; });
int pumps = 0;
s.core().waitVerdict = [] {};
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_USER, 1, &pumps) == VJ_DBG_RESUME_NONE);
CHECK(!s.halted()); // not synced yet
s.syncHalt();
CHECK(s.halted() && s.haltedPc() == 0x4000 && haltUi == 1 && lastReason == VJ_DBG_STOP_USER);
s.syncHalt();
CHECK(haltUi == 1); // the same serial: no second edge
s.core().verdict.store(VJ_DBG_RESUME_STEP);
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_USER) == VJ_DBG_RESUME_STEP);
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4002, VJ_DBG_STOP_STEP, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(s.halted() && s.haltedPc() == 0x4002 && haltUi == 2 && lastReason == VJ_DBG_STOP_STEP && resumeUi == 0);
s.core().verdict.store(VJ_DBG_RESUME_CONTINUE);
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4002, VJ_DBG_STOP_STEP) == VJ_DBG_RESUME_CONTINUE);
s.syncHalt();
CHECK(!s.halted() && resumeUi == 1 && haltUi == 2);
// Halt and continue inside one UI frame: the serial moved by two, the state is "not halted": no edge.
s.core().verdict.store(VJ_DBG_RESUME_CONTINUE);
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT) == VJ_DBG_RESUME_CONTINUE);
s.syncHalt();
CHECK(haltUi == 2 && resumeUi == 1 && !s.halted());
}
// Request 4: "by gdb" = a breakpoint halt at an address not in our list, or a USER / STEP halt we did not ask for.
static void testHaltByGdb() {
DebugSession s; MockDebugIface m; s.core().setIface(m.iface()); s.setContentLoaded(true);
CHECK(s.addBreakpoint(VJ_DBG_TARGET_M68K, 0x4000));
s.core().waitVerdict = [] {};
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_BREAKPOINT, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(s.halted() && !s.haltByGdb());
s.core().frameEnded();
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x5000, VJ_DBG_STOP_BREAKPOINT, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(s.haltByGdb()); // not our breakpoint
s.core().frameEnded();
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x5002, VJ_DBG_STOP_USER, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(s.haltByGdb()); // a halt_next we did not issue (gdb_wait, or gdb's own interrupt)
s.core().frameEnded();
s.breakNow(); // ours: userHaltPending_
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x5004, VJ_DBG_STOP_USER, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(!s.haltByGdb());
}
// Request 4: a verdict for a halt that already ended is dropped (the mirror lags the core by one UI frame).
static void testStaleVerdictDropped() {
DebugSession s; MockDebugIface m; s.core().setIface(m.iface()); s.setContentLoaded(true);
s.core().waitVerdict = [] {};
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000, VJ_DBG_STOP_USER, 1) == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(s.halted());
s.core().frameEnded(); // gdb continued; the UI has not synced yet
s.resume(); // F9 against the stale mirror
CHECK(s.core().verdict.load() == VJ_DBG_RESUME_NONE);
s.syncHalt();
CHECK(!s.halted());
}
// Hardcore from the UI while halted: the DETACH verdict (never iface->detach on the UI thread); while running: a lane
// post. Inline (coreCall / corePost empty) the post runs now.
static void testHardcoreGoesThroughTheLane() {
DebugSession s; MockDebugIface m; s.core().setIface(m.iface()); s.setContentLoaded(true);
CHECK(s.attach());
int posts = 0;
s.corePost = [&](std::function<void()> fn) { posts++; fn(); };
s.setHardcore(true);
CHECK(posts == 1 && !m.attached() && !s.enabled());
s.setHardcore(false);
CHECK(s.attach());
s.core().waitVerdict = [&] { s.syncHalt(); s.setHardcore(true); };
CHECK(m.simulateHalt(VJ_DBG_TARGET_M68K, 0x4000) == VJ_DBG_RESUME_DETACH);
CHECK(posts == 1 && !m.attached()); // the verdict, not a second post
}
Register the four in main.
- Step 2: Run them and watch them fail
Run: cmake --build build -j4 --target vj_debug_session_test 2>&1 | tail -3
Expected: compile errors (syncHalt, onHaltUi, haltByGdb).
-
Step 3: Implement.
debug_session.h/.cpp: the mirror (HaltState mirror_; bool attachedMirror_ = false; bool byGdb_ = false; bool userHaltPending_ = false;),syncHalt()per the Interfaces bullet,halted()/haltedTarget()/haltedPc()/haltReason()/haltSerial()/haltByGdb()/attached()read the mirror.breakNow()setsuserHaltPending_ = truebefore thehaltNextpost.setIface(nullptr)andsetContentLoaded(false)reset the mirror (no edge callbacks).app.cppwireHost():debug.core().abortHalt = [this]{ return quitting_.load(); };debug.core().runUiFrame = [this]{ haltedFrame(); };debug.core().onHalt.push_back([this](int, unsigned, int){ audio.setPaused(true); })(emulation side: atomic),debug.core().onResume.push_back([this]{ audio.clear(); audio.setPaused(paused); })(pausedis read on the emulation thread here: useemuPaused_-style atomic if one exists after Phase 2'ssyncEmuFlags, elseemu.paused()); the toast andpacer_.reset(); lastTick_ = now();move todebug.onHaltUi/debug.onResumeUi(UI thread), the toast text unchanged, plusif (debug.haltByGdb())the toast says"Halted by gdb: %s at $%06X - F9 continues". One unconditional stderr line in the emulation-sideonHalt:[debug] halt: %s at $%06X (%s)with the target and reason names (B5's probe greps it).syncDebug():debug.syncHalt();first, then the existing three lines.startEmuThread(): on successdebug.core().waitVerdict = [this]{ haltWait(); };;liftFeatureLimits()sets it tonullptr.haltWait()per Interfaces.shutdown()(412-425): before thecallForunload:quitting_.store(true); if (debug.halted()) debug.detachAndResume(); emu.debugPost([]{});(the post wakes a pump parked inwaitUntil).stopEmuThread()unchanged. The "it should not happen in phase 1" comment at 418-419 becomes "a detach ends a halt within one pump wait; a core wedged elsewhere still must not hang the quit".emuRunOneFrame()afterhost.runFrame()(654):debug.core().frameEnded();;stepEmulation()after 1833 likewise.haltedFrame(): the guardemu.onEmuThread()becomesthreaded(); it detaches viadebug.detachAndResume()as today (withsyncHalt()first so the mirror lets it write).debug_ui.cpp:78-80:ImGui::TextColored(..., d.haltByGdb() ? "HALTED by gdb" : "HALTED").app.h:void haltWait(); std::atomic<bool> quitting_{false}; bool quitRequested() const { return quit; }.
-
Step 4: Run the tests
Run: cmake --build build -j4 && ctest --test-dir build -R "debug_|emu_thread_unit|smoke_yarc" --output-on-failure
Expected: all green, including inline debug_e2e (whose runUiFrame/onHalt hooks were moved to dbg.core() in 3a or here; the counts in debug_e2e.cpp:137-141 hold).
- Step 5: Commit
git add app/src/debug/debug_session.h app/src/debug/debug_session.cpp app/src/app.h app/src/app.cpp \
app/src/debug/debug_ui.cpp app/tests/debug_session_test.cpp app/tests/debug_e2e.cpp
git commit -F - <<'EOF'
feat(app): the UI mirrors the halt; the emulation thread pumps it (#967)
DebugSession::syncHalt copies DebugCore's HaltState once per UI frame and fires onHaltUi / onResumeUi on serial edges;
F9 / F10 / the halt bar / hardcore write the verdict or post to the debug lane. Threaded, the pump's wait is
App::haltWait (serve the lane, refill the views); quitting sets quitting_ and detaches before the 3 s join. The halt
bar says "HALTED by gdb" for a halt the app did not ask for. Inline keeps haltedFrame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task B3: call() gives up when a halt begins; the call-site policy
Files:
- Modify:
app/src/emu_thread.h:84-93, 144-172(call(F&&, std::function<bool()> giveUp = {})),app/src/app.h/app.cpp(haltGiveUp(),detachForCall(); call sites:openContent1064-1068,bootBios1195-1199,closeContent1238-1243,reset1264-1270,handleDrop1480-1492,saveState1514-1534,loadState1556-1581,autoSave,runHeadlessFrames898-907,setBiosChoice1212, the SRAM flush posts stay),app/src/ui.cpp:554,560-566,583-591(menu gates: "Close game"/Reset/Open tray/Disc enabled while halted again; Save/Load state stay disabled with the tooltip "resume it first (F9)"; the Core options tab gets one line),app/src/debug/op_ui.cpp:484-488("step one frame" refuses while halted),app/src/core_host.cpp:838,928,1030,1042(texts:"the debugger has the game halted (resume it first: F9)"),app/src/debug/debug_ui.cpp(halt bar line) - Test:
app/tests/emu_thread_test.cpp
Interfaces:
-
Produces:
template <class F> auto EmuThread::call(F&& fn, std::function<bool()> giveUp = {}) -> decltype(fn());(header contract: polled on the waiting thread every slice, 16-50 ms; true before the task started -> the task is marked cancelled, skipped when drained,call()returnsR(); a task already running completes; never consulted inline / on the emulation thread / after the start). Implementation shape: the queued wrapper is[task, started, cancelled] { if (cancelled->load()) return; started->store(true); (*task)(); }with twostd::shared_ptr<std::atomic<bool>>; the waiter's loop checksgiveUp && !started->load() && giveUp()after eachwait_forslice and, when true, doescancelled->store(true)and re-checksstarted(a task that began between the two reads is waited for:done.get()); otherwise returnsR(). -
App:std::function<bool()> haltGiveUp() const;=[this]{ return debug.core().halt().halted; }(any-thread read);std::function<bool()> detachForCall();= threaded anddebug.halted():const uint64_t s0 = debug.core().haltSerial(); debug.detachAndResume(); return [this, s0]{ return debug.core().halt().halted && debug.core().haltSerial() != s0; }(gives up only on a new halt; the one being detached still reads halted for a few ms), elsehaltGiveUp(). -
Policy (resolved-ambiguities table): refuse while
debug.halted()with the toast"The debugger has the game halted - resume it first (F9)":saveState(quiet variants silent),loadState,bootBios,runHeadlessFrames/OP step one frame (emu.advance),autoSave. Detach first (threaded):openContent,closeContent,reset,handleDrop(throughopenContent), disc eject/index/insert (ui.cpp:585,591 andshowDiscDialog's insert), quit (B2). Inline keeps today's refusal for all of them (a call runs at once insideretro_run, core_host.cpp refuses withinFrame_):detachForCall()inline returns{}after the existinghaltedNow()toast path, i.e. theif (haltedNow()) { toast; return }blocks stay for!threaded(). Every remaining UI-initiatedemu.callthat is not in those lists (setBiosChoice,achcoreCalls,audio.clearin headless) passeshaltGiveUp()and treatsR()as "not now". -
"Applies after Continue":
drawDebugOverlayaddsImGui::TextDisabled("Settings and cheats apply after Continue")under the buttons;ui.cpp's Core options tab (drawCoreOptions, line 361's callee) starts with the sameTextDisabledline whenapp.debug.halted(). -
Step 1: Write the failing tests. In
app/tests/emu_thread_test.cpp, beforemain:
// Review Focus 1: a call posted an instant before the core halts. With a give-up predicate it returns R() once the
// predicate turns true, and the cancelled task never runs; a task that already started is waited for.
void testCallGivesUp() {
EmuThread e;
FakeClient c;
c.block = true; // a frame that never ends: the halt
CHECK(e.start(EmuThread::Mode::Threaded, c), "start");
for (int i = 0; i < 1000 && !c.blocked.load(); i++) sleepMs(1);
std::atomic<bool> halted{false};
std::atomic<int> polls{0};
auto ran = std::make_shared<std::atomic<bool>>(false);
std::thread flip([&] { sleepMs(60); halted = true; });
const Clock::time_point t0 = Clock::now();
const int r = e.call([ran] { *ran = true; return 42; }, [&] { polls++; return halted.load(); });
flip.join();
CHECK(r == 0, "the call returned R() (%d)", r);
CHECK(since(t0) < 0.3, "within 300 ms (%.3f s)", since(t0));
CHECK(polls.load() >= 1, "the predicate was polled (%d)", polls.load());
c.block = false;
sleepMs(100); // the frame ends, the queue drains
CHECK(!ran->load(), "the cancelled task never ran");
// A task that started before the predicate turned true runs to completion and returns its value.
halted = false;
const int r2 = e.call([&] { sleepMs(120); halted = true; sleepMs(30); return 7; }, [&] { return halted.load(); });
CHECK(r2 == 7, "a started task is waited for (%d)", r2);
// Never consulted inline or on the emulation thread.
std::atomic<int> consulted{0};
CHECK(e.call([&] { return e.call([] { return 1; }, [&] { consulted++; return true; }); }) == 1, "on the thread: runs");
CHECK(consulted.load() == 0, "predicate ignored on the emulation thread (%d)", consulted.load());
CHECK(e.stop(), "stop");
EmuThread in;
FakeClient ic;
CHECK(in.start(EmuThread::Mode::Inline, ic), "inline");
CHECK(in.call([] { return 3; }, [&] { consulted++; return true; }) == 3 && consulted.load() == 0, "inline: runs, never asks");
}
Register it in main.
- Step 2: Run it and watch it fail
Run: cmake --build build -j4 --target vj_emu_thread_test 2>&1 | tail -3
Expected: compile error (call takes one argument).
-
Step 3: Implement.
emu_thread.h: the newcallper Interfaces (the header comment from the Interfaces section above the declaration).callForunchanged.app.cppcall sites per the policy.saveState: after theisLoadedcheck,if (debug.halted()) { if (!quiet) toast("The debugger has the game halted - resume it first (F9)", 2.5, 2); return false; }andemu.call([&]{...}, haltGiveUp()); onR()(false) witherr.empty(): the same toast.loadStatethe same.openContent: replace 1065-1068 withif (haltedNow() && !threaded()) { toast(...); return false; } auto giveUp = detachForCall();andemu.call([&]{...}, giveUp); on false witherr.empty():err = "the debugger halted the game again".closeContent,reset,bootBios(refuse: keep 1196-1199 as is, text updated),handleDrop(no change:openContentdecides; itsflushSaveRamcall getshaltGiveUp()),runHeadlessFramesand op_ui.cpp:484 (if (app.debug.halted()) return;with the hint text "resume the debugger first (F9)"). ui.cpp:554/560:host.isLoaded()only for Close game, Reset, Open/Close tray, Disc (they detach first);Save state/Load state/State manager:loaded && !app.haltedNow()withImGui::SetItemTooltip("Resume the debugger first (F9)")when disabled by the halt; ui.cpp:585,591 calls passapp.detachForCall().- core_host.cpp: the four texts.
- The halt bar and options tab lines.
-
Step 4: Run the tests
Run: cmake --build build -j4 && ctest --test-dir build -R "emu_thread_unit|debug_|smoke_.*_threaded|smoke_state" --output-on-failure
Expected: all green; emu_thread_unit repeated --repeat until-fail:10 stays green.
- Step 5: Commit
git add app/src/emu_thread.h app/src/app.h app/src/app.cpp app/src/ui.cpp app/src/core_host.cpp \
app/src/debug/op_ui.cpp app/src/debug/debug_ui.cpp app/tests/emu_thread_test.cpp
git commit -F - <<'EOF'
feat(app): a call() gives up when a halt begins; content changes detach first (#967)
EmuThread::call takes a give-up predicate: a call queued behind a frame that halts returns instead of parking the UI
behind the busy spinner. Threaded, open / close / reset / disc / quit detach the debugger first and then proceed;
save / load state, boot and frame advance refuse with a toast; frame-lane posts wait for Continue and the halt bar
and the Core options tab say so. Inline keeps refusing (the call would run inside retro_run).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task B4: the GDB stub unlocked, a silent host behind every halt, hardcore locks the stub
Files:
- Modify:
app/src/app.cpp(limitFeaturesForEmuThread533-544 andliftFeatureLimits547-554 deleted with their call sites at 1953 and 605;wireHost'soptionLock482-484 gains the hardcore rule;syncDebuggainssyncGdbHost();startEmuThreadlogs[emu] emulation thread onas before),app/src/app.h(syncGdbHost,std::atomic<bool> gdbLockedByHardcore_{false},bool gdbHostTried_ = false),app/src/achievements.cpp(no code: Decision 2 is implemented in App's lock; the doc comment at 586-591 notes the stub),app/src/core_host.cpp:1206-1240(no change) - Test:
app/cmake/VJEmuThreadTests.cmake(gdb_locked_threaded199-204 ->gdb_enabled_threaded; newgdb_hardcore_locked,gdb_hardcore_locked_threaded;emu_thread_limits194-198 ->debug_on_threaded),app/cmake/VJAchTests.cmake(nothing; the two hardcore tests live next to the other gdb tests)
Interfaces:
-
Produces:
void App::syncGdbHost();UI thread, fromsyncDebug():if (!host.isLoaded() || !debug.enabled()) { gdbHostTried_ = false; return; } if (gdbHostTried_ || debug.attached()) return; gdbHostTried_ = true; if (host.optionValueFor("virtualjaguar_gdb_stub", "disabled") == "enabled" && debug.attach()) std::fprintf(stderr, "[debug] gdb host attached (silent): halts are visible to the app\n");.setContentLoaded(false)/setIfacepaths resetgdbHostTried_through the!isLoadedbranch. The core reads the stub options at load only (libretro.c:6898-6945), so one attach per load is the whole policy. -
The lock:
wireHost()'shost.optionLockbecomes[this](const std::string& k, std::string& v) { if (k == "virtualjaguar_gdb_stub" && gdbLockedByHardcore_.load()) { v = "disabled"; return true; } return lockedCoreOptionFor(k, v, {{...}}); };syncDebug()writesgdbLockedByHardcore_fromach.hardcoreActive()and on a change callshost.refreshLocks()(core_host.cpp:243 queues it to the owner). Hardcore-on restarts the game (achievements.cpp:479-482), so the locked value is what the restarted core reads. -
Log lines (tests grep them):
[emu] emulation thread on(exists, app.cpp:610);[debug] gdb host attached (silent); the(locked)verbose option dump already printsvirtualjaguar_gdb_stub=disabled (locked)(aspro_controller_option_onrelies on). -
Step 1: Write the failing tests. In
app/cmake/VJEmuThreadTests.cmake, replace lines 194-204 with:
# 3b: nothing is switched off while threaded. The old "are off" line must be gone, the thread must be on.
add_test(NAME debug_on_threaded
COMMAND virtualjaguar --headless --emu-thread on --frames 5 "${VJ_ROMS}/yarc.j64")
set_tests_properties(debug_on_threaded PROPERTIES LABELS emu_thread TIMEOUT 120
PASS_REGULAR_EXPRESSION "\\[emu\\] emulation thread on"
FAIL_REGULAR_EXPRESSION "are off while the emulation thread is on|ThreadSanitizer|owner violation")
# The GDB stub is a plain core option again when threaded, and the app attaches its silent host behind it.
add_test(NAME gdb_enabled_threaded
COMMAND virtualjaguar --headless --emu-thread on --frames 5 --option virtualjaguar_gdb_stub=enabled
--option virtualjaguar_gdb_port=0 "${VJ_ROMS}/yarc.j64")
set_tests_properties(gdb_enabled_threaded PROPERTIES LABELS emu_thread TIMEOUT 120
PASS_REGULAR_EXPRESSION "\\[debug\\] gdb host attached \\(silent\\)"
FAIL_REGULAR_EXPRESSION "managed by the app|rejected by the core|ThreadSanitizer|owner violation")
# Decision 2: RetroAchievements hardcore locks the stub to disabled, inline and threaded.
foreach(_m off on)
add_test(NAME gdb_hardcore_locked_${_m}
COMMAND virtualjaguar --headless --emu-thread ${_m} --frames 10 --verbose --ra-mock "${VJ_RA_FIX}" --ra-hardcore
--option virtualjaguar_gdb_stub=enabled "${VJ_ROMS}/yarc.j64")
set_tests_properties(gdb_hardcore_locked_${_m} PROPERTIES TIMEOUT 120
PASS_REGULAR_EXPRESSION "virtualjaguar_gdb_stub=disabled \\(locked\\)"
FAIL_REGULAR_EXPRESSION "gdb host attached|headless: FAIL|ThreadSanitizer|owner violation")
endforeach()
set_tests_properties(gdb_hardcore_locked_on PROPERTIES LABELS emu_thread)
VJ_RA_FIX is set in VJAchTests.cmake:33; include that file before this one in app/CMakeLists.txt if it is not already, or repeat its two lines here. Check how --ra-hardcore interacts with --option: the override of a locked key must not count as badOverride_ (app.cpp:2519-2522 only fires on a core rejection; a lock is not one, as pro_controller_option_on shows). Update the header comment list at lines 23-24 (*_threaded limits -> debug_on_threaded / gdb_enabled_threaded nothing is off while threaded; gdb_hardcore_locked_* hardcore locks the stub). Headless: syncDebug() must run at least once per load for the silent attach: in runHeadless after the load (app.cpp:2503) add syncDebug(); (it is cheap and inline-safe); the interactive loop already calls it every frame.
- Step 2: Run them and watch them fail
Run: cmake -S app -B build >/dev/null && cmake --build build -j4 && ctest --test-dir build -R "debug_on_threaded|gdb_enabled_threaded|gdb_hardcore_locked" --output-on-failure
Expected: debug_on_threaded fails on the "are off" line, gdb_enabled_threaded on the missing attach line (the stub option is still locked), gdb_hardcore_locked_* on the missing (locked).
-
Step 3: Implement per Interfaces: delete
limitFeaturesForEmuThread/liftFeatureLimitsandemu_thread_start_fallback's expectation changes to"could not start the emulation thread; running emulation on the UI thread"(the limits no longer exist;host.announceDebugstays true always;debug.core().waitVerdictis cleared in the failure branch ofstartEmuThread).syncGdbHost(), the lock,refreshLocks()on the edge. The transitional halt gate goes with the limits: deleteDebugSession::setHaltAvailable/haltAvailable/haltDisabledReasonandhaltAvailable_(toolAvailablebecomesenabled() && t.toggle && (!t.needsHalt || halted()),attach()/breakNow()/addBreakpoint()lose the term), the A5 tooltip "Halting is not available with the emulation thread yet" incpu_ui.cpp, the F9 /debugBreakContinueelse toast(debug.haltDisabledReason(), ...)branches (app.cpp, desktop.cpp) and the Break menu item's tooltip (debug_ui.cpp); in the tests deletetestHaltAvailable(debug_session_test.cpp), thes.setHaltAvailable(false)lines indebug_cpu_ui_smoke.cppanddebug_scope_gate_test.cpp(theiroffCore() == 0checks stay), and case 6 ofdebug_views_threaded.cpp./usr/bin/grep -rn "haltAvailable\|haltDisabledReason\|setHaltAvailable" app/ docs/app-debug-tools.md app/README.mdmust print nothing. -
Step 4: Run the tests
Run: ctest --test-dir build -L emu_thread --output-on-failure -j4 && ctest --test-dir build -R "^ra_|debug_" --output-on-failure -j4
Expected: all green, emu_thread_start_fallback with its new regex.
- Step 5: Commit
git add app/src/app.h app/src/app.cpp app/src/achievements.cpp app/cmake/VJEmuThreadTests.cmake
git commit -F - <<'EOF'
feat(app): GDB stub unlocked while threaded; a silent host behind every halt; hardcore locks the stub (#967)
The phase 1 limits are gone: the core always announces VJ_CAP_DEBUG and virtualjaguar_gdb_stub is a plain option again
when the emulation thread is on. Whenever the stub is enabled and a game is loaded the app attaches its host, so a gdb
halt flows through the app's pump: the halt bar shows it, calls give up, quitting detaches. RetroAchievements hardcore
locks the stub to disabled, as it does cheats (phase 3 plan, decisions 2 and 3).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task B5: debug_e2e_threaded, gdb_threaded_halt, the signal quit
Files:
- Create:
app/tests/debug_e2e_threaded.cpp,app/tests/gdb_threaded_halt.py - Modify:
app/src/main.cpp:92(signal handlers),app/src/app.h/app.cpp(quitRequested()readsg_signalQuit;abortHalt; the headless loop 2547-2554 breaks onquitRequested()and printsheadless: quit requested, exiting cleanly;run()returns 0 then),app/cmake/VJDebugTests.cmake(vj_debug_e2e_threaded),app/cmake/VJEmuThreadTests.cmake(gdb_threaded_halt, python-gated),app/CMakeLists.txt(find_package(Python3 COMPONENTS Interpreter)if absent) - Test: the two new tests;
debug_e2e(inline) unchanged
Interfaces:
-
Consumes: everything B1-B4 produced;
CoreHost,EmuThread,DebugSession/DebugCoreas App wires them (the rig below mirrorswireHost/startEmuThread/haltWaitexactly so the e2e pins App's policy without App). -
Produces:
vj::g_signalQuit(app/src/app.h:extern std::atomic<bool> g_signalQuit;), the handlers inmain.cpp, installed only for--headlessruns (std::signal(SIGINT, h); std::signal(SIGTERM, h);wherehstores true;<csignal>; windowed runs keep SDL's own handlers, which turn the signal intoSDL_EVENT_QUIT),bool App::quitRequested() const { return quit || g_signalQuit.load(); },abortHalt = [this]{ return quitting_.load() || g_signalQuit.load(); }. -
Step 1: Write the failing tests.
app/tests/debug_e2e_threaded.cpp (modelled on emu_thread_e2e.cpp's Rig and debug_e2e.cpp's steps; CHECK as there; usage: vj_debug_e2e_threaded <rom.j64>):
// The debugger halt on the emulation thread, with the REAL core: this (test) thread plays the UI. Every UI-side
// operation below is timed: none may block over 50 ms (the window keeps drawing while the game is halted).
struct Rig : EmuThread::Client {
CoreHost& host = CoreHost::instance();
EmuThread emu;
DebugSession dbg;
std::atomic<bool> quitting{false};
std::atomic<int> haltsEmu{0}, resumesEmu{0};
void threadStarted() override { host.setOwner(std::this_thread::get_id()); }
void threadStopping() override { host.setOwner(std::thread::id()); }
bool canRun() override { return host.isLoaded(); }
double framePeriod() override { return 1.0 / 60.0; }
double audioQueuedMs() override { return -1.0; }
void runFrame() override { host.runFrame(); dbg.core().frameEnded(); host.publishSnapshot(); }
void afterCommands() override { host.publishSnapshot(); }
void wire() { // what App::wireHost + startEmuThread do
host.onDebugIface = [this](const vj_debug_iface* i) { dbg.core().setIface(i); };
host.post = [this](std::function<void()> fn) { emu.post(std::move(fn)); };
dbg.coreCall = [this](std::function<void()> fn) { emu.debugCall([&] { fn(); }); };
dbg.corePost = [this](std::function<void()> fn) { emu.debugPost(std::move(fn)); };
dbg.core().notifyVerdict = [this] { emu.debugPost([] {}); };
dbg.core().waitVerdict = [this] { emu.serveDebugLane(DebugCore::kPumpWait); };
dbg.core().abortHalt = [this] { return quitting.load(); };
dbg.core().onHalt.push_back([this](int, unsigned, int) { haltsEmu++; });
dbg.core().onResume.push_back([this] { resumesEmu++; });
}
};
// Runs `fn` on this thread and checks it took under 50 ms.
template <class F> void ui(const char* what, F fn) { const auto t0 = Clock::now(); fn(); const double s = since(t0);
if (s > 0.05) { g_fail++; std::fprintf(stderr, "FAIL %s blocked the UI thread for %.0f ms\n", what, s * 1e3); } }
// Polls until `cond` or 5 s.
template <class F> bool until(F cond) { ... 2 ms sleeps ... }
Sections in main (one Rig r; r.wire(); r.emu.start(Threaded, r); emu.call(loadContent), dbg.setContentLoaded(true), until(framesRun >= 30)):
- Break / step / continue from the UI thread.
ui("breakNow", [&]{ r.dbg.breakNow(); });CHECK(until([&]{ r.dbg.syncHalt(); return r.dbg.halted(); }));CHECK(r.dbg.haltReason() == VJ_DBG_STOP_USER && !r.dbg.haltByGdb());const uint64_t f = r.host.frameCount();sleepMs(100); CHECK(r.host.frameCount() == f);(no frames while halted)const unsigned pc1 = r.dbg.haltedPc(); const uint64_t s1 = r.dbg.haltSerial();ui("step", [&]{ r.dbg.step(); }); CHECK(until([&]{ r.dbg.syncHalt(); return r.dbg.haltSerial() != s1 && r.dbg.halted(); }));CHECK(r.dbg.haltReason() == VJ_DBG_STOP_STEP && r.dbg.haltedPc() != pc1 && r.resumesEmu == 0);ui("resume", [&]{ r.dbg.resume(); }); CHECK(until([&]{ r.dbg.syncHalt(); return !r.dbg.halted(); })); CHECK(r.resumesEmu == 1); CHECK(until([&]{ return r.host.frameCount() > f; })); - A debug call while halted answers within 50 ms (the pump serves the lane): halt again (
breakNow+until halted);ui("read", [&]{ std::vector<uint8_t> b; CHECK(r.dbg.read(0x7000, b, 4)); });ui("poke", [&]{ CHECK(r.dbg.pokeU(0x7000, 4, 0xCAFEF00D) == 4); });vj_debug_regs regs; ui("regs", [&]{ CHECK(r.dbg.getRegsNow(VJ_DBG_TARGET_M68K, regs)); }); CHECK(regs.pc == r.dbg.haltedPc()); - Review Focus 5: a frame-lane option post made while halted applies only after Continue.
r.host.setOptionValue("virtualjaguar_blitter_timing", "enabled");(a post, threaded)sleepMs(60); r.host.pinSnapshot(); CHECK(r.host.optionValueFor("virtualjaguar_blitter_timing", "unset") == "unset");const uint64_t f3 = r.host.frameCount(); ui("resume", [&]{ r.dbg.resume(); }); CHECK(until([&]{ r.host.pinSnapshot(); return r.host.optionValueFor("virtualjaguar_blitter_timing", "unset") == "enabled"; }));CHECK(r.host.frameCount() >= f3 + 1);thenresetOptionToDefault. - Review Focus 1: a save state requested just before a halt returns false within 300 ms (deterministic: a
halt_nextarmed from inside the halt re-halts on the very next instruction after Continue, before the frame can end, so the call queued in the frame lane sits behind the new halt).breakNow+until halted;r.dbg.coreCall([&]{ r.dbg.core().haltNext(VJ_DBG_TARGET_M68K); });std::atomic<bool> saved{false}; std::string err; std::thread caller([&]{ const auto t0 = Clock::now(); const bool ok = r.emu.call([&]{ return r.host.saveStateToFile(stateFile, err); }, [&]{ return r.dbg.core().halt().halted && r.dbg.core().haltSerial() != s4; }); saved = ok; tCall = since(t0); });wheres4is the serial of the current halt taken beforeresume();sleepMs(5); ui("resume", [&]{ r.dbg.resume(); }); caller.join();CHECK(!saved && err.empty() && tCall < 0.3);CHECK(until([&]{ r.dbg.syncHalt(); return r.dbg.halted() && r.dbg.haltSerial() != s4; }));CHECK(r.dbg.haltReason() == VJ_DBG_STOP_USER);thenresume()andCHECK(r.emu.call([&]{ return r.host.saveStateToFile(stateFile, err); }, [&]{ return r.dbg.core().halt().halted; }));(no halt now: the save runs). - Detach-first: a content call after
detachAndResume()runs within one frame and nothing re-halts. Halt at a breakpoint the game revisits:addBreakpoint(M68K, pc1);until halted;const uint64_t s5 = r.dbg.haltSerial(); ui("detach", [&]{ r.dbg.detachAndResume(); });const auto t0 = Clock::now(); CHECK(r.emu.call([&]{ return r.host.loadContent(rom, err); }, [&]{ return r.dbg.core().halt().halted && r.dbg.core().haltSerial() != s5; }));CHECK(since(t0) < 0.5); r.dbg.setContentLoaded(false); r.dbg.setContentLoaded(true); sleepMs(200); r.dbg.syncHalt(); CHECK(!r.dbg.halted() && !r.dbg.attached() && r.dbg.breakpoints().empty());(the list is per core instance). - Hardcore from the UI while halted.
breakNow+ halted;std::string closed; r.dbg.onForcedClose.push_back([&](const char* w){ closed = w; });ui("hardcore", [&]{ r.dbg.setHardcore(true); });CHECK(until([&]{ r.dbg.syncHalt(); return !r.dbg.halted(); })); CHECK(!r.dbg.attached() && closed == "hardcore"); CHECK(until([&]{ return frames advanced; })); r.dbg.setHardcore(false); - Review Focus 6: stop() returns true within 500 ms while halted.
breakNow+ halted;r.quitting = true; ui("detachAndResume", [&]{ r.dbg.detachAndResume(); }); r.emu.debugPost([]{});const auto t0 = Clock::now(); CHECK(r.emu.callFor(std::chrono::milliseconds(3000), [&]{ r.host.unloadContent(); })); CHECK(r.emu.stop()); CHECK(since(t0) < 0.5);Then the same withquittingonly (no verdict): a freshRig+ load + halt,r.quitting = true; r.emu.debugPost([]{});CHECK(r.emu.stop() && since(t0) < 0.5)(abortHalt alone frees the join). - A halt during a debugCall issued from the UI (named deadlock case):
breakNow()postshaltNextto the debug lane; the next UI action isr.dbg.read()(adebugCall): the lane is drained at the frame boundary before the frame that halts, or inside the pump once halted: either way it returns.ui("breakNow+read", [&]{ r.dbg.breakNow(); std::vector<uint8_t> b; CHECK(r.dbg.read(0, b, 16)); }); CHECK(until(halted)); resume.
Register in app/cmake/VJDebugTests.cmake next to vj_debug_e2e: add_executable(vj_debug_e2e_threaded tests/debug_e2e_threaded.cpp src/debug/debug_session.cpp src/debug/debug_core.cpp src/debug/debug_views.cpp src/core_host.cpp src/emu_thread.cpp src/paths.cpp), the same includes/links as vj_debug_e2e plus Threads::Threads (and ws2_32 iphlpapi on WIN32 as vj_emu_thread_e2e), add_test(NAME debug_e2e_threaded COMMAND vj_debug_e2e_threaded "${VJ_ROMS}/yarc.j64"), set_tests_properties(debug_e2e_threaded PROPERTIES TIMEOUT 300 LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL |owner violation").
app/tests/gdb_threaded_halt.py (in the style of test/tools/gdb_breakpoint_probe.py: recv_packet with a persistent rxbuf, cmd() that discards unsolicited T05 replies; usage: gdb_threaded_halt.py <virtualjaguar> <rom>):
- Pick a port:
bind(("127.0.0.1", 0)), read it, close (the core binds moments later; a collision fails the run, which is what the first assert reports). - Launch
[app, "--headless", "--emu-thread", "on", "--frames", "300", "--option", "virtualjaguar_gdb_stub=enabled", "--option", "virtualjaguar_gdb_wait=enabled", "--option", f"virtualjaguar_gdb_port={port}", rom]withstderr=PIPE(a reader thread collects lines), connect within 5 s,+,qSupported, expect the boot-wait stopT05thread:1;within 10 s. - Assert the app's stderr shows
[debug] gdb host attached (silent)and[debug] halt: 68000 at $within 2 s;m0,10answers 32 hex chars while halted;sleep 0.5and the app is still alive (proc.poll() is None). c-> the app finishes its 300 frames:proc.wait(timeout=30)returns 0 and stdout ends withheadless: OK (1 load(s), emulation thread).- Second launch, same arguments; after the stop reply send
proc.send_signal(SIGTERM)while holding the halt;proc.wait(timeout=3)returns 0 and stderr hasheadless: quit requested, exiting cleanlyand nodid not stop within 3 s. (Windows: skip step 5 with a printed note;SIGTERMthere is a hard kill.) - Print
PASS: gdb halt on the emulation thread, memory while halted, continue, quit mid-haltand exit 0.
Register in app/cmake/VJEmuThreadTests.cmake (after find_package(Python3 COMPONENTS Interpreter) in app/CMakeLists.txt):
# 3b: a gdb client halts the app at boot while the emulation thread is on; the app's silent host sees it, memory is
# served while halted, continue finishes the run, and a quit while gdb still holds the halt exits 0 within 3 s.
if(Python3_Interpreter_FOUND)
add_test(NAME gdb_threaded_halt
COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_LIST_DIR}/../tests/gdb_threaded_halt.py"
$<TARGET_FILE:virtualjaguar> "${VJ_ROMS}/yarc.j64")
set_tests_properties(gdb_threaded_halt PROPERTIES TIMEOUT 120 LABELS emu_thread
PASS_REGULAR_EXPRESSION "^PASS: gdb halt on the emulation thread"
FAIL_REGULAR_EXPRESSION "owner violation|ThreadSanitizer|did not stop within 3 s")
endif()
Header-comment lines for both in their cmake files.
- Step 2: Run them and watch them fail
Run: cmake -S app -B build >/dev/null && cmake --build build -j4 --target vj_debug_e2e_threaded && ctest --test-dir build -R "debug_e2e_threaded|gdb_threaded_halt" --output-on-failure
Expected: debug_e2e_threaded builds and passes sections 1-7 already if B1-B4 are right (it is the integration pin; a failure here is a B1-B4 bug to fix there); gdb_threaded_halt fails at step 5 (no signal handler: the app is killed, exit code -15, no quit requested line).
-
Step 3: Implement the signal path per Interfaces:
main.cpphandlers beforeApp::run;App::quitRequested();abortHalt; the headless threaded loop (2547-2554) becomesfor (...; i < a.frames && !quitRequested();)with the message andrc = 0after the loop whenquitRequested(); the inline headless loop (2558-) the same check per frame. The interactive loop'swhile (!quit)becomeswhile (!quitRequested()). -
Step 4: Run the tests
Run: cmake --build build -j4 && ctest --test-dir build -L emu_thread --output-on-failure -j4 && ctest --test-dir build -R "debug_" --output-on-failure -j4 && ctest --test-dir build -R "debug_e2e_threaded" --repeat until-fail:5
Expected: all green, five repeats green. On a ThreadSanitizer configure (-DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo, TSAN_OPTIONS="halt_on_error=1 second_deadlock_stack=1 suppressions=$PWD/app/tests/tsan.supp", build dir build-tsan): ctest -L emu_thread green with app/tests/tsan.supp unchanged; a report is fixed by publishing the value read (a mirror field, an atomic), never by a suppression.
- Step 5: Commit
git add app/tests/debug_e2e_threaded.cpp app/tests/gdb_threaded_halt.py app/src/main.cpp app/src/app.h app/src/app.cpp \
app/cmake/VJDebugTests.cmake app/cmake/VJEmuThreadTests.cmake app/CMakeLists.txt
git commit -F - <<'EOF'
test(app): the halt and the GDB stub end to end on the emulation thread (#967)
debug_e2e_threaded drives break / step / continue / detach / hardcore / quit from a UI thread that never blocks over
50 ms, pins the option post that applies after Continue, the save state that gives up when a halt begins, and the
3 s join. gdb_threaded_halt halts a threaded headless run from a gdb client, reads memory, continues, and quits the
app mid-halt (SIGTERM now ends a headless run cleanly, which the halted case needs).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task B6: docs, Studio checks, the PR
Files:
-
Modify:
docs/app-debug-tools.md("Halt model" 23-41, "Break, continue, step" 143-149, "Hardcore" 151-155, "With the external GDB stub" 163-168, "Testing" 177-),app/README.md:523-534(the "Emulation thread" section: the Tools menu and the GDB stub now work threaded; the stub is locked under hardcore),docs/agent/testing.md("App emulation thread tests":debug_session_unitlabel,debug_e2e_threaded,gdb_threaded_halt,debug_on_threaded,gdb_enabled_threaded,gdb_hardcore_locked_*, the SIGTERM quit),docs/app-emulation-thread-design.md(section 6 amendment:frameEnded()and the serial wording, three sentences),docs/app-emulation-thread-plan-phase3.md(this plan's "Decisions to confirm" 2 and 3 marked as shipped once the maintainer agrees) -
Step 1: Docs. "Halt model" point 2 becomes: with an app host attached,
GDBHalt()callshalt_pumpevery iteration; threaded, the pump serves the debug lane, waits at most 4 ms and returns NONE (the core's loop keeps the socket and the halt timeout); inline it runs one UI frame as before. Point 3: while parked, frame-lane commands (options, cheats, controller, SRAM autosave) wait in the queue and apply after Continue; save/load state, boot and frame advance refuse with a toast; open/close/reset/disc/quit detach first. Add to the GDB section: the silent host, "HALTED by gdb", the hardcore lock, and that quitting clears gdb's breakpoints. Testing: the new tests by name. README: drop "the Tools menu and the GDB stub stay off"; add the hardcore lock sentence. -
Step 2: Commit the docs
git add docs/app-debug-tools.md app/README.md docs/agent/testing.md docs/app-emulation-thread-design.md docs/app-emulation-thread-plan-phase3.md
git commit -F - <<'EOF'
docs(app): the halt and the GDB stub on the emulation thread (#967)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
- Step 3: Studio checks (windowed runs only there;
uptimeunder 2 first;VJR=~/Workspace/Provenance/vj-eval/remote/vjr.sh):
uptime
bash $VJR push emu-p3b HEAD
bash $VJR run emu-p3b env -u DEVELOPER_DIR bash -lc 'uptime; cmake -S app -B app/build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build app/build -j14 >/dev/null && ctest --test-dir app/build -C Release -E shaders_gl -j6 2>&1 | tail -5'
Then, windowed (app/tests/emu_thread_windowed_check.sh as the Phase 1/2 runs, with these arguments): (a) --emu-thread on --show-window cpu --option virtualjaguar_gdb_stub=enabled --option virtualjaguar_gdb_wait=enabled yarc.j64 with VJ_PERF_CSV set, 20 s: the window starts halted at boot (HALTED by gdb), the CPU window shows registers, iter_ms stays at the display period while halted (p99 under 2x the period), F9 continues, F9 breaks again, F10 steps, Detach; grep the log for owner violation and ThreadSanitizer (none). (b) The same on a -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF build of the app, with tsan.supp as committed: no report. (c) A gdb session (gdb_stock_gdb_check.sh's gdb, or python3 app/tests/gdb_threaded_halt.py against the windowed binary with --headless removed by hand) holding the halt while the app is quit from the window: the process exits within 3 s, log has no did not stop within 3 s.
Expected: all three as stated. Any owner violation or TSan report is a bug fixed in B2-B4 before the PR.
- Step 4: Open the PR. Body at
~/Workspace/Provenance/vj-eval/remote/emu-p3b/pr-body.md:
Refs #967 (phase 3b of docs/app-emulation-thread-plan-phase3.md; design: docs/app-emulation-thread-design.md section 6)
With Settings > Interface > Emulation thread on, the debugger halt (F9/F10, breakpoints, step) and the GDB stub now
work: the core's halt pump runs on the emulation thread, bounded to 4 ms per call, and the UI mirrors the halt once
per frame. Still off by default in every build.
- `DebugCore::pump`: serves the debug lane, waits <= 4 ms, returns NONE to `GDBHalt()`'s own loop; verdicts are an
atomic the UI writes; `frameEnded()` closes a halt gdb ended.
- Calls while halted: frame-lane posts apply after Continue (the halt bar says so); save/load state, boot and frame
advance refuse; open/close/reset/disc/quit detach first; `EmuThread::call` gives up when a halt begins.
- GDB: the option is unlocked while threaded; the app attaches a silent host whenever the stub is enabled
(decision 3), the halt bar says "HALTED by gdb", quitting detaches; hardcore locks the stub (decision 2).
- Headless runs end cleanly on SIGINT/SIGTERM (needed to quit a run gdb holds halted).
Decisions to confirm (plan, "Decisions to confirm" 2 and 3): the hardcore lock and the silent host.
## Studio (M4 Max, 60 Hz)
| check | result |
|---|---|
| full suite | |
| windowed: halt at boot with the CPU window, UI iter_ms p99 while halted | |
| windowed TSan run, same scenario | |
| quit while gdb holds the halt: exit time | |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
git push -u libretro feat/967-emu-thread-phase3b
gh pr create --repo libretro/virtualjaguar-libretro --base develop --head feat/967-emu-thread-phase3b \
--title "feat(app): emulation thread phase 3b -- the debugger halt and the GDB stub on the emulation thread (#967)" \
--body-file ~/Workspace/Provenance/vj-eval/remote/emu-p3b/pr-body.md
Expected: CI green (App *, ThreadSanitizer (emulation thread), pr-issue-link). The agent does not merge. After the merge: git worktree remove ~/Workspace/Provenance/vj-wt/emu-p3b.
Named races and deadlocks, and the test that pins each
| Case | Mechanism | Test |
|---|---|---|
| A call posted an instant before a halt | give-up predicate; the queued wrapper skips a cancelled task | emu_thread_unit testCallGivesUp; debug_e2e_threaded section 4 |
| The busy spinner while halted | never reached: the call gives up before kBusyAfter only if the predicate is given; refused calls never post |
debug_e2e_threaded 50 ms budget on every UI op |
| Quit mid-halt | quitting_ -> abortHalt -> DETACH within one pump wait; verdict + lane notify first |
debug_session_unit testDetachEndsPumpWithinOneWait, testAbortHaltDetaches; debug_e2e_threaded 7; gdb_threaded_halt step 5 |
| gdb and app verdicts racing | first verdict wins in GDBHalt; a foreign resume is closed by frameEnded(), which also drops a stale verdict; a verdict for a dead halt is dropped by the serial check |
testFrameEndedClosesForeignResume, testStaleVerdictDropped |
| gdb step inside the same frame | new-halt detection by (target, pc) | testForeignStepIsNewHalt |
A halt during a UI debugCall |
the lane is drained at the boundary before the halting frame or by the pump inside it | debug_e2e_threaded 8 |
| Inline: the pump runs a UI frame on the same thread | waitVerdict empty, runUiFrame set, verdicts written by that frame; content calls refuse (they would run inside retro_run) |
testInlinePumpRunsUiFrame; debug_e2e unchanged |
| The pump outwaits the socket service | bounded by waitVerdict only |
testPumpNeverOutwaitsHook |
| Hardcore mid-halt | DETACH verdict, no iface call on the UI thread | testHardcoreGoesThroughTheLane; debug_e2e_threaded 6 |
| Detach-first then re-halt on a gdb breakpoint | the serial-based predicate gives up only on a new halt; the silent host re-attaches on the next syncGdbHost() after the load |
debug_e2e_threaded 5 |
Sub-phase 3c: Neuro on the input snapshot
Branch feat/967-emu-thread-phase3c off libretro/develop after 3a merges; worktree ~/Workspace/Provenance/vj-wt/emu-p3c. Depends on 3a for DebugViews, App::fillDebugViews() and the interface reaching the app threaded (announceDebug true, debug.core().iface() set; halts stay impossible until 3b). Independent of 3b; the shared hunks are App::wireHost (this PR touches only the inputPoll lambda) and startEmuThread. The PR leaves off the nightly default (3d) and everything about halts and gdb. It ends with Neuro on in every threaded run: no gate, no hint, no neuro_off_threaded.
The design in one paragraph: the UI keeps InputQueue as the append-only record of what was accepted (trimmed by progress), publishes it as an AgentProgram, and the core's input poll runs an AgentPlayer replica one step per emulated frame (pollAgent), inline and threaded alike. Agent::busy is the UI queue's: true from the moment an action is accepted until a matching-lineage progress shows it played out. Watches are one DebugViews request per profile; the UI reads the bytes at 4 Hz.
What each Review Focus / #1025 item is pinned by
| Behaviour | Test (task) |
|---|---|
| Review Focus 3: a press lasts N core polls at any display rate / fast-forward / inline vs threaded | neuro_unit "AgentPlayer presses for exactly N polls, hold persists, gen reload replaces" (C1); neuro_core_e2e "9 polls, free-running, at 600 Hz" (C2); neuro_loopback_threaded with the inline loopback frame-numbered vector (C2 harness, C4 registration) |
| An appended action / a hold change mid-play neither replays nor drops frames | neuro_unit "append and hold change mid-play equal one uninterrupted queue" (C1) |
#1025 release input on drop (server drops, release_all, link down) |
loopback / loopback_threaded rightNow cleared after the server closes (C2/C4); neuro_unit "release_all drops queue and hold at the next poll" (C2) |
#1025 hardcore block, including tick()'s early return |
neuro_unit "hardcore clears the replica within one poll" (C2); loopback upNow cleared on hardcore (C2/C4); loopbackHardcoreFirst unchanged |
| #1025 input block (menu / mouse), nothing played behind the menu | testAgentGating ported (C2); AgentPlayer "disabled does not advance" (C1) |
| #1025 bounded stop | testStopIsPrompt unchanged and green; the replica has no wait (nothing to add) |
#1025 indicators (driving(), "Pressing:") |
testAgentGating pressedNow assertions through progress (C2) |
| Inline smoke hashes unchanged | .any() guard in addAgentControls; every smoke_* test (C2 step 6) |
Task C1: AgentPlayer, the snapshot's agent channel, InputQueue additions (unit)
Files:
- Modify:
app/src/input_snapshot.h(add aftertakenSince, :48; members after :57) - Modify:
app/src/neuro/neuro_actions.h(InputQueue:56-72; newAgentPlayer,pollAgentafter it; includeinput_snapshot.h) - Modify:
app/src/neuro/neuro_actions.cpp(InputQueue:158-190; new definitions after :190) - Modify:
app/cmake/VJNeuroTests.cmake(thevj_neuro_testtarget :9-20) - Test:
app/tests/neuro_test.cpp(newtestAgentPlayer,testQueueProgram, registered inmainaftertestQueue, :883),app/tests/input_snapshot_test.cpp(newtestAgentChannel)
Interfaces:
-
Consumes:
ControlSet,CoreInput(remap.h),Step. -
Produces:
AgentStep,AgentProgram,AgentProgress(Interfaces section;AgentStep { ControlSet held; unsigned frames = 1; }),InputSnapshot::{publishAgent, takeAgent, reportAgent, agentProgress}(Interfaces-section signatures),InputQueue::{version, lineage, firstFrame, trim, program},AgentPlayer::{load, frame, progress, gen},pollAgent. -
Step 1: link what the snapshot needs into
vj_neuro_test.InputSnapshotholds aCoreInput, whosecarryMotion/clearMotionlive inremap.cpp. InVJNeuroTests.cmakechange the target toadd_executable(vj_neuro_test tests/neuro_test.cpp src/remap.cpp src/config.cpp src/paths.cpp ${VJ_NEURO_SOURCES})and its include line totarget_include_directories(vj_neuro_test PRIVATE src src/neuro $<TARGET_PROPERTY:vj_core,INTERFACE_INCLUDE_DIRECTORIES>)(the same three sources and generator expression asvj_input_snapshot_test, VJEmuThreadTests.cmake:61-62). -
Step 2: write the failing tests.
input_snapshot_test.cpp,testAgentChannel()(add tomain; plainCHECK(cond, "msg")style of that file):InputSnapshot s; AgentProgram p, got; CHECK(!s.takeAgent(got), "nothing published: false"); p.gen = 1; p.lineage = 1; p.enabled = true; p.steps = {{pad(C_B), 3}}; s.publishAgent(p); CHECK(s.takeAgent(got) && got.gen == 1 && got.steps.size() == 1 && got.steps[0].frames == 3, "first take"); CHECK(!s.takeAgent(got), "same gen: false"); p.gen = 2; s.publishAgent(p); p.gen = 3; p.hold = pad(C_A); s.publishAgent(p); CHECK(s.takeAgent(got) && got.gen == 3 && got.hold == pad(C_A), "two publishes between takes: the newest only"); AgentProgress r; r.gen = 3; r.lineage = 1; r.played = 2; r.pressed = pad(C_B); s.reportAgent(r); CHECK(s.agentProgress().played == 2 && s.agentProgress().pressed == pad(C_B), "progress round trip");plus a two-thread tear check (label already
emu_thread): the UI thread publishes gen 1..20000 wheresteps.size() == gen % 7andhold.bits == gen; the emulation thread loopstakeAgentandreportAgent, asserting each taken program satisfies both relations andgennever decreases; final take has gen 20000.neuro_test.cpp:static AgentProgram prog(uint64_t gen, uint64_t lineage, uint64_t first, std::vector<AgentStep> steps, ControlSet hold = ControlSet(), bool enabled = true) { AgentProgram p; p.gen = gen; p.lineage = lineage; p.firstFrame = first; p.enabled = enabled; p.hold = hold; p.steps = steps; return p; } static std::string pat(AgentPlayer& pl, unsigned n, Ctl c) { // one char per poll: the letter when c is pressed std::string s; for (unsigned i = 0; i < n; i++) s += pl.frame().test(c) ? '#' : '.'; return s; }testAgentPlayer()(every line aCHECK):- "AgentPlayer presses for exactly N polls":
pl.load(prog(1,1,0,{{keys({C_B}),3},{ControlSet(),3},{keys({C_B}),3}}));pat(pl,12,C_B) == "###...###..."; a fresh player after 3 polls hasprogress().played == 3 && !done; after 9,played == 9 && done. - "hold persists":
hold = keys({C_A}), steps{{keys({C_B}),2}}: polls 1-2== keys({C_A,C_B}), polls 3-6== keys({C_A})(steps exhausted, hold stays);progress().pressed == keys({C_A}),played == 2,done. - "gen reload replaces (new lineage)": after (1)
load(prog(9,8,0,{{keys({C_A}),2}})): nextframe() == keys({C_A}),progress().played == 1 && lineage == 8 && gen == 9. - "append and hold change mid-play equal one uninterrupted queue": reference
InputQueue ref; ref.enqueue({{B,6},{UP,2},{ControlSet(),2},{B,1}}),ref.setHoldnot used, 11ref.frame()values recorded. Player:load(prog(1,7,0,{{B,6}})), 2 polls;load(prog(2,7,0,{{B,6},{UP,2}})), 3 polls;load(prog(3,7,5,{{B,1},{UP,2},{ControlSet(),2},{B,1}}))(the UI trimmed the 5 played frames), 6 polls. The 11 polled values equal the reference's;progress().played == 11. Then with a hold change:load(prog(4,7,11,{}, keys({C_C})))-> nextframe() == keys({C_C})andplayedunchanged. - "disabled does not advance":
load(prog(1,1,0,{{B,4}})), 1 poll;load(prog(2,1,0,{{B,4}}, ControlSet(), false)): 3 polls all!any(),progress().played == 1;load(prog(3,1,0,{{B,4}}))resumes:pat(3,C_B) == "###"andplayed == 4. - "firstFrame ahead of played is clamped": fresh player,
load(prog(1,1,5,{{B,3}})):pat(4,C_B) == "###.",played == 8. pollAgent:InputSnapshot s; AgentPlayer pl; s.publishAgent(prog(1,1,0,{{B,2}})):pollAgent(s,pl)three times== keys({C_B}), keys({C_B}), ControlSet(),s.agentProgress().played == 2 && done && gen == 1.
testQueueProgram():InputQueue q; q.version()==0&&q.lineage()==0;enqueue({{B,3},{A,2}})->version()==1;setHold(keys({C_C}))-> 2;setHoldagain with the same set -> still 2;program(7,true)hasgen==7, enabled, hold==C, steps.size()==2, steps[0].frames==3, firstFrame==0, lineage==q.lineage();q.trim(4)->queuedFrames()==1 && firstFrame()==4,program(8,true).steps.size()==1 && .firstFrame==4,peek()has A;q.trim(100)->!busy() && firstFrame()==5;q.clear()->lineage()==1,firstFrame()==0,version()increased, hold gone. - "AgentPlayer presses for exactly N polls":
-
Step 3: run, expect FAIL (does not compile).
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j4 --target vj_neuro_test vj_input_snapshot_testExpected: errors "unknown type name 'AgentProgram'" / "no member named 'publishAgent'".
-
Step 4: implement.
input_snapshot.h: the three structs exactly as in change request 1 (AgentStepfirst); the four methods with the Interfaces-section signatures. A separatestd::mutex agentMu_(the per-eventmu_is not touched) andstd::atomic<uint64_t> agentGen_{0};takeAgentreturns false without locking whenagentGen_ == takenAgentGen_(emulation-thread-only member), else locks, copies, setstakenAgentGen_ = out.gen.publishAgentstores under the lock, thenagentGen_.store(p.gen).reportAgent/agentProgresscopy underagentMu_.neuro_actions.h/.cpp:InputQueuegets membersuint64_t version_ = 0, lineage_ = 0, firstFrame_ = 0;and the methods of change request 2 (enqueuesuccess,setHold-when-changed andclearbumpversion_;clearalso doeslineage_++, firstFrame_ = 0;program(gen, enabled)converts eachSteptoAgentStep). Everything existing (frame,peek,busy,queuedFrames) is unchanged: the player reuses them.AgentPlayer(membersInputQueue q_; bool enabled_ = false; uint64_t gen_ = 0, lineage_ = 0, played_ = 0; ControlSet last_;):void AgentPlayer::load(const AgentProgram& p) { if (p.lineage != lineage_) { lineage_ = p.lineage; played_ = 0; } // a new lineage restarts the count if (played_ < p.firstFrame) played_ = p.firstFrame; // frames trimmed away can never be replayed q_.clear(); std::vector<Step> st; for (const AgentStep& a : p.steps) st.push_back({a.held, a.frames}); if (!st.empty()) q_.enqueue(st); q_.trim((unsigned)(played_ - p.firstFrame)); // skip what this replica already played q_.setHold(p.hold); enabled_ = p.enabled; gen_ = p.gen; } ControlSet AgentPlayer::frame() { // !enabled: empty and does NOT advance (nothing plays behind a menu) if (!enabled_) return last_ = ControlSet(); const bool had = q_.busy(); last_ = q_.frame(); if (had) played_++; return last_; } // progress(): {gen_, lineage_, (unsigned)played_, !q_.busy(), last_} ControlSet pollAgent(InputSnapshot& s, AgentPlayer& p) { AgentProgram prog; if (s.takeAgent(prog)) p.load(prog); const ControlSet c = p.frame(); s.reportAgent(p.progress()); return c; }
-
Step 5: run, expect PASS.
cmake --build build -j4 --target vj_neuro_test vj_input_snapshot_test ctest --test-dir build -R "neuro_unit|input_snapshot_unit" --output-on-failureExpected: both PASS;
neuro unit: ok. -
Step 6: commit.
git add app/src/input_snapshot.h app/src/neuro/neuro_actions.h app/src/neuro/neuro_actions.cpp app/cmake/VJNeuroTests.cmake app/tests/neuro_test.cpp app/tests/input_snapshot_test.cpp git commit -m "feat(app): AgentPlayer and the snapshot's agent channel (#967) Co-Authored-By: Claude Opus 5.5 <[email protected]>"(Write the message with its trailer on its own line; commits are signed through 1Password, retry on a signing error.)
Task C2: Agent publishes and absorbs; agentControls removed; inputPoll runs the replica
Files:
- Modify:
app/src/neuro/neuro_agent.h(:34-37GameStatestays for now; :72frame()removed; :79pressedNow; new methods; header comment :1-7) - Modify:
app/src/neuro/neuro_agent.cpp(configure:52-72,setInputBlock:95-101,tick:148-163) - Modify:
app/src/neuro/neuro_app.cpp(neuroTick:124-135,neuroShutdown:137-140, newneuroHeadlessReport) - Modify:
app/src/input.h(:53-54 removed,addAgentControlsadded),app/src/input.cpp(:612 removed, definition added) - Modify:
app/src/app.cpp(inputPoll:462-478; headless loops :2534-2557 and :2557-2570; end-of-run line after :2573),app/src/app.h(neuro::AgentPlayer agentPlayer_next toinputSnap;neuroHeadlessReportdeclaration :540) - Create:
app/tests/neuro_core_e2e.cpp - Modify:
app/cmake/VJNeuroTests.cmake - Test:
app/tests/neuro_test.cpp(porttestAgentGating:408-490 andloopback:765-872; newtestAgentProgram; newappmode)
Interfaces:
-
Consumes: C1 (
AgentProgram,pollAgent,InputQueue::{version,lineage,firstFrame,trim,program}). -
Produces:
void Agent::publishTo(InputSnapshot&),void Agent::absorb(const AgentProgress&),uint64_t Agent::framesPlayed() const,ControlSet Agent::pressedNow() const(= the last matching-lineage progress'spressed; empty when not allowed),InputManager::addAgentControls(ControlSet),void neuroHeadlessReport(App&). Removed:Agent::frame(),InputManager::agentControls. -
Step 1: write the failing tests.
Test helper in
neuro_test.cpp(shared by unit and loopback):struct Replica { // what the app's input poll does, for a test with no core InputSnapshot snap; AgentPlayer player; ControlSet poll(Agent& a) { a.publishTo(snap); const ControlSet c = pollAgent(snap, player); a.absorb(snap.agentProgress()); return c; } };testAgentGatingis ported by replacing eachagent.frame()withrep.poll(agent)(aReplica rep;at the top); every assertion stays as written, so they are the #1025 pins: the menu clears what is queued (!rep.poll(agent).any() && !agent.pressedNow().any()straight afteragent.tick(0.1, g)), nothing is queued behind the menu, the mouse reason, noactions/forcewhile blocked, and hardcore: afteragent.configure(s)withs.hardcore = true(note:tick()is not called, as inneuroTick, which callsconfigurethentickwhich returns early)!rep.poll(agent).any() && !agent.pressedNow().any().New
testAgentProgram():Agent agent; Replica rep; std::vector<Json> sent; agent.link().send = [&](const std::string& m) { sent.push_back(parsed(m)); }; AgentSettings s; s.enabled = true; s.url = "ws://127.0.0.1:9"; agent.configure(s); GameState g; g.loaded = true; g.title = "Tempest 2000"; agent.tick(0.0, g); agent.link().onConnected(); const std::string hold = "{\"command\":\"action\",\"data\":{\"id\":\"h\",\"name\":\"hold_buttons\",\"data\":\"{\\\"buttons\\\":[\\\"right\\\"]}\"}}"; const std::string press = "...press_buttons b frames 3..."; // the string testAgentGating uses, frames:3- "busy covers progress from an older gen":
agent.link().onMessage(press)thenagent.publishTo(rep.snap); before any pollagent.absorb(rep.snap.agentProgress())(default progress: gen 0, lineage 0) leavesqueue busy, observed as:agent.tick(1.0, g)withforceInterval = 0sends noactions/force. After threerep.poll(agent)calls the nextagent.tick(2.0, g)does send one, andagent.framesPlayed() == 3. - "release_all drops queue and hold at the next poll":
onMessage(hold),onMessage(press),rep.polltwice (right+b pressed),onMessage(release_all),rep.poll(agent)is empty and!agent.pressedNow().any(), and the following three polls too. - "hardcore clears the replica within one poll": hold right,
rep.pollpresses right;s.hardcore = true; agent.configure(s);withoutagent.tick;rep.poll(agent)is empty andrep.snap.takeAgentwas seen withenabled == false(recordrep.playerstate:rep.player.progress().pressedempty);agent.configureback tohardcore = falseand a fresh press plays again. - "turning Neuro off clears the replica" (the
neuroShutdownpath): hold right, poll,agent.configure(AgentSettings()),rep.poll(agent)empty. - "append mid-play does not replay": press b 6 frames, 2 polls, press up 2 frames, then 6 polls: the pressed pattern across the 8 polls is
bbbbbbthen up on 2:B B B B B B U Uexactly (the replica keptplayed).
loopback()port: the app thread'sagent.frame()(:780) becomesrep.poll(agent)withReplica rep;local to theappthread (single thread, inline-shaped), everything else (pressedB == 9,rightNow,upNowflags, hardcore) unchanged. Additionally record the frame-numbered vector for C4: once the firstC_Bpoll is seen, appendpressed.test(C_B)per poll to astd::vector<uint8_t> recuntil it holds 15 entries; after theforce2wait assertrec == {1,1,1,0,0,0,1,1,1,0,0,0,1,1,1}(firex3 = three 3-frame presses with a 3-frame gap, the Tempest macro). Extract the loop body intostatic int loopbackRun(bool threadedPoll); this task registers onlythreadedPoll == false.New
appmode inmain(POSIX;return 77on Windows):vj_neuro_test app <virtualjaguar> <rom> <on|off>opens aFakeServer, spawns the app withpopen(<exe> --headless --emu-thread <mode> --neuro ws://127.0.0.1:<port> --frames 480 <rom> 2>&1, a reader thread collects the output),accept(10000),waitFor(startup),waitFor(actions/register), sends{"command":"action","data":{"id":"p1","name":"press_buttons","data":"{\"buttons\":[\"b\"],\"frames\":30}"}},waitFor(action/result)must besuccess, then drains messages until the child exits (pclose, status 0). The output must contain[neuro] headless: 30 frames of input played, not containowner violationorThreadSanitizer, and foroncontainemulation thread).neuro_core_e2e.cpp(usagevj_neuro_core_e2e <rom.j64>; copyRig/sessionfromemu_thread_e2e.cpp:84-137, droppingFrameExchangeand the pixel hash; the record per frame isfnv(joypad0Buttons, 21)):host.inputPoll = [&]{ rig.snap.take(rig.cur); const ControlSet a = pollAgent(rig.snap, rig.player); if (a.any()) rig.cur.addControls(0, a); };host.inputStateas there. Three sessions of 120 frames, lock-step (emu.advance(1)per frame, program published before frame 60):- idle: no program (
base). - "9 polls, inline":
Mode::Inline, programprog(1,1,0,{{pad(C_B),3},{ControlSet(),3},{pad(C_B),3},{ControlSet(),3},{pad(C_B),3}})published before frame 60.rec[i] != base[i]for exactlyi in {60,61,62,66,67,68,72,73,74}. - "same threaded":
Mode::Threaded;rec == rec(2). - "free-running at 600 Hz": threaded, not paused, program published after
emu.framesRun() >= 30; wait untilemu.framesRun() >= 200; the number of frames withrec[i] != base[i](compare against the idle matrix value, not indices) is exactly 9. - disabled: program with
enabled=false:rec == base.
- "busy covers progress from an older gen":
-
Step 2: run, expect FAIL (does not compile). Register in
VJNeuroTests.cmake:add_executable(vj_neuro_core_e2e tests/neuro_core_e2e.cpp src/core_host.cpp src/emu_thread.cpp src/remap.cpp src/config.cpp src/paths.cpp src/neuro/neuro_actions.cpp src/neuro/neuro_json.cpp) target_include_directories(vj_neuro_core_e2e PRIVATE src src/neuro) target_link_libraries(vj_neuro_core_e2e PRIVATE vj_core ${VJ_SDL3_TARGET} Threads::Threads) if(WIN32) target_link_libraries(vj_neuro_core_e2e PRIVATE ws2_32 iphlpapi) endif() vj_emu_thread_warnings(vj_neuro_core_e2e) add_test(NAME neuro_core_e2e COMMAND vj_neuro_core_e2e "${VJ_ROMS}/yarc.j64") set_tests_properties(neuro_core_e2e PROPERTIES TIMEOUT 600 LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL ") add_dependencies(vj_neuro_test virtualjaguar) add_test(NAME neuro_app_inline COMMAND vj_neuro_test app $<TARGET_FILE:virtualjaguar> "${VJ_ROMS}/yarc.j64" off) set_tests_properties(neuro_app_inline PROPERTIES TIMEOUT 120 RUN_SERIAL TRUE SKIP_RETURN_CODE 77 FAIL_REGULAR_EXPRESSION "FAIL |ThreadSanitizer|owner violation")(
vj_emu_thread_warningsis defined inVJEmuThreadTests.cmake; include order in app/CMakeLists.txt puts it first. IfVJNeuroTestsis included earlier, repeat its two-line body.)cmake --build build -j4 --target vj_neuro_test vj_neuro_core_e2e virtualjaguar ctest --test-dir build -R "neuro_unit|neuro_core_e2e|neuro_app_inline" --output-on-failureExpected: compile errors for
publishTo/absorb/framesPlayed. -
Step 3: implement
Agent. Inneuro_agent.h: removeframe();pressedNow()returnspressed_(aControlSetmember); addvoid publishTo(InputSnapshot&),void absorb(const AgentProgress&),uint64_t framesPlayed() const; privateuint64_t programGen_ = 0, pubVersion_ = 0, pubLineage_ = 0, playedTotal_ = 0, lastLineage_ = ~0ull, lastPlayed_ = 0; bool pubEnabled_ = false; ControlSet pressed_;. Update the header comment (threads:tick,configure,publishTo,absorbare UI-thread; the replica isAgentPlayer, driven by the core's input poll). Bodies:void Agent::publishTo(InputSnapshot& s) { const bool en = allowed() && actions_.blocked().empty(); if (queue_.version() == pubVersion_ && queue_.lineage() == pubLineage_ && en == pubEnabled_) return; s.publishAgent(queue_.program(++programGen_, en)); pubVersion_ = queue_.version(); pubLineage_ = queue_.lineage(); pubEnabled_ = en; } void Agent::absorb(const AgentProgress& p) { if (p.lineage == lastLineage_) { if (p.played > lastPlayed_) playedTotal_ += p.played - lastPlayed_; } else playedTotal_ += p.played; lastLineage_ = p.lineage; lastPlayed_ = p.played; const bool mine = p.lineage == queue_.lineage(); // an older lineage's progress says nothing about this queue pressed_ = mine && allowed() ? p.pressed : ControlSet(); if (mine && p.played > queue_.firstFrame()) queue_.trim((unsigned)(p.played - queue_.firstFrame())); }queue_.busy()(used bytick,canForce) is unchanged and now means "accepted and not yet seen played out", which is why a force never goes out the instant an action is accepted.stateTextkeepsqueue_.hold().setInputBlock,configure,pump,gameChangedalready callqueue_.clear(); no change. -
Step 4: implement the app side.
input.h: deleteagentControlsand its comment (:53-54); addvoid addAgentControls(ControlSet c);.input.cpp: delete :612; definevoid InputManager::addAgentControls(ControlSet c) { if (c.any()) core_.addControls(0, c); }.app.h: under#ifdef VJ_HAVE_NEUROa memberneuro::AgentPlayer agentPlayer_; // emulation thread only (the input poll); declarevoid neuroHeadlessReport(App& app);.app.cpphost.inputPoll(:462-478), both branches:// threaded branch, after `if (ns) noteInputLatency(ns);`: #ifdef VJ_HAVE_NEURO { const ControlSet a = neuro::pollAgent(inputSnap, agentPlayer_); if (a.any()) emuInput_.addControls(0, a); } #endif return; // inline branch: delete `input.agentControls = neuro.frame();`; after `input.poll(!inputCaptured_);`: #ifdef VJ_HAVE_NEURO input.addAgentControls(neuro::pollAgent(inputSnap, agentPlayer_)); #endifneuro_app.cpp:neuroTickbecomes: config as today,app.neuro.configure(app.neuroCfg); app.neuro.absorb(app.inputSnap.agentProgress()); app.neuro.tick(t, gameState(app)); app.neuro.publishTo(app.inputSnap);(absorb before tick so an action accepted insidetick's pump sees a trimmed queue; publish after, unconditionally, sotick's early return on!allowed()does not hide a clear).neuroShutdown: afterconfigure(AgentSettings())callapp.neuro.publishTo(app.inputSnap). New:void neuroHeadlessReport(App& app) { // --neuro --headless: what the run saw, for the tests app.neuro.absorb(app.inputSnap.agentProgress()); std::fprintf(stderr, "[neuro] headless: %llu frames of input played\n", (unsigned long long)app.neuro.framesPlayed()); }app.cppheadless: in the inline loop callneuroHeadlessReport(*this)once after theTIMINGline when!neuroCliUrl.empty()(underVJ_HAVE_NEURO). The threaded headless branch is C4's.- Delete
Agent::frameuses nowhere else (grep -rn "neuro.frame\|agentControls" app/src app/testsmust be empty).
-
Step 5: run, expect PASS.
cmake --build build -j4 && ctest --test-dir build -R "neuro|input_snapshot|emu_thread_e2e" --output-on-failureExpected:
neuro_unit,neuro_loopback,neuro_core_e2e,neuro_app_inline,neuro_off_threaded(still gated off, unchanged),input_snapshot_unit,emu_thread_e2ePASS. -
Step 6: the inline hashes did not move.
ctest --test-dir build -R "smoke_|cheat_|pro_controller" --output-on-failure: all PASS (the.any()guard keepscore_untouched with no agent). -
Step 7: commit.
git add app/src/neuro app/src/input.h app/src/input.cpp app/src/app.cpp app/src/app.h app/cmake/VJNeuroTests.cmake app/tests/neuro_test.cpp app/tests/neuro_core_e2e.cpp git commit -m "feat(app): Neuro presses through the input snapshot (#967) Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task C3: RAM watches through DebugViews
Files:
- Modify:
app/src/neuro/neuro_agent.h(GameState:34-37; newwatchRange,kMaxWatchSpan),app/src/neuro/neuro_agent.cpp(stateText:165-179,watchRange) - Modify:
app/src/neuro/neuro_actions.h/.cpp(watchValueoverload :90, :295-312) - Modify:
app/src/neuro/neuro_app.cpp(gameState:95-96, theretro_get_memory_*externs :22-25,neuroTick, new watch request code) - Modify:
app/src/app.h(DebugViews::Id neuroWatchId,uint64_t neuroWatchKey,std::vector<uint8_t> neuroWatchBytes,uint32_t neuroWatchBase,double neuroWatchAtbeside :185-188),app/src/app.cpp(headless hooks) - Modify:
app/cmake/VJNeuroTests.cmake - Test:
app/tests/neuro_test.cpp(testWatches), CTestneuro_watch_inline
Interfaces:
-
Consumes: 3a's
DebugViews(set,clear,read,ViewRequest::Kind::{Memory,Dram},ViewBuffer::{ok,bytes}),debug.views(),App::fillDebugViews(),App::syncDebug(). -
Produces:
std::vector<uint8_t> GameState::watchBytes; uint32_t GameState::watchBase(replacingram,ramSize),bool Agent::watchRange(uint32_t& base, unsigned& len) const(false without watches),constexpr unsigned kMaxWatchSpan = 65536, the 4-argumentwatchValue. -
Step 1: write the failing tests.
testWatches()inneuro_test.cpp:uint8_t b[4] = {0x12, 0x34, 0x00, 0x07}; // bytes of a request whose first address is 0x1F0000 Watch w; w.address = 0x1F0000; w.size = 2; w.format = "bcd"; CHECK(watchValue(w, b, sizeof b, 0x1F0000) == "1234"); w.address = 0x1F0003; w.size = 1; w.format = "dec"; CHECK(watchValue(w, b, sizeof b, 0x1F0000) == "7"); w.address = 0x1F0004; CHECK(watchValue(w, b, sizeof b, 0x1F0000) == "?"); // past the end w.address = 0x1EFFFF; CHECK(watchValue(w, b, sizeof b, 0x1F0000) == "?"); // before the base CHECK(watchValue(w, nullptr, 0, 0) == "?");and an
Agenttest with a profile loaded from a temp directory (std::filesystem::temp_directory_path() / "vj-neuro-watch", a filet.json={"game_name":"T","titles":["Tempest 2000"],"watch":[{"name":"score","address":"0x100","size":2,"format":"bcd"},{"name":"lives","address":256,"size":1}]};agent.loadProfiles(dir);agent.tick(0.0, g)withg.title = "Tempest 2000"sofollowGamepicks it):watchRange(base,len)is true withbase == 0x100 && len == 2; with a third watch at0x1F0000size 1 in a second fileu.json(titles ["Wide"])watchRangegivesbase == 0x100 && len == 0x1EFF01, which is> kMaxWatchSpan; a profile without watches returns false.stateTextwithg.watchBase = 0x100,g.watchBytes = {0x12,0x34}contains- score: 1234and- lives: 18; with emptywatchBytesit contains- score: ?.CTest (fixture written at configure time like
pro_controller_option_*, VJEmuThreadTests.cmake:100-105):set(_nw "${CMAKE_CURRENT_BINARY_DIR}/neuro-watch-data") file(MAKE_DIRECTORY "${_nw}/neuro") file(WRITE "${_nw}/neuro/yarc.json" "{\"game_name\":\"Yarc\",\"titles\":[\"yarc\"],\"watch\":[{\"name\":\"cheat byte\",\"address\":\"0x1F0000\",\"size\":1,\"format\":\"hex\"},{\"name\":\"far byte\",\"address\":\"0x100000\",\"size\":1}]}") add_test(NAME neuro_watch_inline COMMAND virtualjaguar --headless --emu-thread off --frames 90 --data-dir "${_nw}" --cheat 001F0000:AB --neuro ws://127.0.0.1:9 "${VJ_ROMS}/yarc.j64") set_tests_properties(neuro_watch_inline PROPERTIES TIMEOUT 120 PASS_REGULAR_EXPRESSION "\\[neuro\\] state: .*cheat byte: 0xAB" FAIL_REGULAR_EXPRESSION "headless: FAIL|owner violation|ThreadSanitizer")The two watches are 1 MiB apart, so this run takes the
Kind::Drampath (span > 64 KiB); the fixture's second profile line is what pins "no watch is dropped". -
Step 2: run, expect FAIL (
cmake --build build -j4 --target vj_neuro_testfails: no 4-argumentwatchValue, nowatchRange). -
Step 3: implement.
watchValue4-arg: indexaddress - base; "?" whenbytesis null,address < baseoraddress - base + size > n; the 3-arg form calls it withbase = 0.Agent::watchRange: over the current profile's watches,base = min(address),len = max(address + size) - base; false with no profile or no watches.stateTextcallswatchValue(w, g.watchBytes.data(), g.watchBytes.size(), g.watchBase);GameStatedropsram/ramSizeand gains the two members. Remove the externs and the tworetro_get_memory_*lines inneuro_app.cpp.neuro_app.cpp, a staticvoid neuroWatches(App& app)called at the end ofneuroTick(afterpublishTo):(uint32_t base = 0; unsigned len = 0; const bool want = app.neuro.allowed() && app.host.isLoaded() && app.neuro.watchRange(base, len); const bool dram = want && len > neuro::kMaxWatchSpan; const uint64_t key = want ? ((uint64_t)dram << 63) | ((uint64_t)base << 32) | len : 0; if (key != app.neuroWatchKey) { // a fresh id for a new range: the old buffer can never be read as the new one if (app.neuroWatchId) app.debug.views().clear(app.neuroWatchId); app.neuroWatchId = DebugViews::kNoId; app.neuroWatchBytes.clear(); app.neuroWatchKey = key; if (want) { ViewRequest r; r.kind = dram ? ViewRequest::Kind::Dram : ViewRequest::Kind::Memory; r.addr = dram ? 0 : base; r.len = dram ? 0 : len; app.neuroWatchId = app.debug.views().set(DebugViews::kNoId, r); app.neuroWatchBase = dram ? 0 : base; } } if (app.neuroWatchId && t - app.neuroWatchAt >= 0.25) { // 4 Hz: a 2 MiB Dram copy is not made every UI frame app.neuroWatchAt = t; ViewBuffer vb; if (app.debug.views().read(app.neuroWatchId, vb) && vb.ok) app.neuroWatchBytes = std::move(vb.bytes); else app.neuroWatchBytes.clear(); }tisneuroTick's clock.)neuroTickhands the cached bytes totickwithout a copy: afterGameState g = gameState(app);dog.watchBase = app.neuroWatchBase; g.watchBytes = std::move(app.neuroWatchBytes); app.neuro.tick(t, g); app.neuroWatchBytes = std::move(g.watchBytes);.static_assert(neuro::kMaxWatchSpan == DebugViews::kMaxMemoryBytes)in this file.read()fails across a content switch (epoch) and before the first fill, so the state text says "?" rather than showing the previous game or stale bytes. Neuro never callsiface->peekitself.- Headless needs the copier too, because the headless loops bypass
emuRunOneFrame()/stepEmulation(): inheadlessNeuroFrame(app.cpp:153) addsyncDebug();afterneuroTick(*this);(movesrefreshSerial()at 10 Hz as the windowed loop does;fill()only refills on a new serial), and in the inline headless loop afterhost.runFrame()(app.cpp:2556) addif (!neuroCliUrl.empty()) fillDebugViews();(3a's hook; inline it runs on this thread).neuroHeadlessReportadditionally prints the state line:(exposestd::string st = app.neuro.stateText(gameStateForReport(app)); // same builder neuroTick uses, with the cached bytes for (char& c : st) if (c == '\n') c = '|'; std::fprintf(stderr, "[neuro] state: %s\n", st.c_str());gameStatetoneuroHeadlessReportby moving the call insideneuro_app.cpp; it is already file-local there.) fillDebugViews()(=DebugSession::fillViews()) has no gate andwakeViews()is gated only on "a core and content" (Interfaces, A3), so a Tools-window state never starves the watches;debug.views()is reachable whiledebug.enabled()is false. Nothing to add here; if a gate is found, it is a 3a bug.
-
Step 4: run, expect PASS.
cmake --build build -j4 && ctest --test-dir build -R "neuro_unit|neuro_watch_inline|neuro_app_inline" --output-on-failureExpected: PASS;
[neuro] state: ... cheat byte: 0xAB ...in the log. If the profile does not match, printtitleOf's result once (the fixture title must match case-insensitively at the start of the title the library derives fromyarc.j64; adjusttitlesto that, not the code). -
Step 5: commit.
git add app/src/neuro app/src/app.h app/src/app.cpp app/cmake/VJNeuroTests.cmake app/tests/neuro_test.cpp git commit -m "feat(app): Neuro reads RAM watches through a DebugViews request (#967) Co-Authored-By: Claude Opus 5.5 <[email protected]>"
Task C4: lift the gate, the threaded twins, docs, PR
Files:
- Modify:
app/src/neuro/neuro_app.cpp(settingsFrom:52,neuroInit:111-112,drawNeuroTab:171, header comment :1-10),app/src/neuro/neuro_agent.h(threading comment :1-7),app/src/neuro/neuro_actions.h(comment :15-17:InputManager::agentControls) - Modify:
app/src/app.cpp(threaded headless branch :2534-2557; theinputPollcomment from C2) - Modify:
app/cmake/VJNeuroTests.cmake(header comment :1-8;neuro_off_threaded:26-31) - Modify:
app/tests/neuro_test.cpp(loopback-threadedmode) - Modify:
docs/neuro.md(:30-34, :130-131),app/README.md(:847-860),docs/agent/testing.md(the Neuro/threaded test rows) - Test:
neuro_on_threaded,neuro_watch_threaded,neuro_loopback_threaded(CTest)
Interfaces:
-
Consumes: C1-C3.
-
Produces: none (the three CTest names above replace
neuro_off_threaded). -
Step 1: write the failing tests (CTest). Replace the
neuro_off_threadedblock (VJNeuroTests.cmake:26-31) with:# #967 phase 3c: with the emulation thread on, Neuro works. The twins of the inline tests above, frame for frame. add_test(NAME neuro_on_threaded COMMAND vj_neuro_test app $<TARGET_FILE:virtualjaguar> "${VJ_ROMS}/yarc.j64" on) add_test(NAME neuro_watch_threaded COMMAND virtualjaguar --headless --emu-thread on --frames 90 --data-dir "${_nw}" --cheat 001F0000:AB --neuro ws://127.0.0.1:9 "${VJ_ROMS}/yarc.j64") add_test(NAME neuro_loopback_threaded COMMAND vj_neuro_test loopback-threaded) set_tests_properties(neuro_on_threaded neuro_loopback_threaded PROPERTIES TIMEOUT 120 RUN_SERIAL TRUE SKIP_RETURN_CODE 77) set_tests_properties(neuro_on_threaded neuro_watch_threaded neuro_loopback_threaded PROPERTIES LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL |headless: FAIL|owner violation|ThreadSanitizer|off for this run") set_tests_properties(neuro_watch_threaded PROPERTIES TIMEOUT 120 PASS_REGULAR_EXPRESSION "\\[neuro\\] state: .*cheat byte: 0xAB")In
neuro_test.cppmain:if (mode == "loopback-threaded") return loopbackRun(true) ...(Windows: 77).loopbackRun(true)runs the C2 harness with the poll on its own thread: the UI thread keepsconfigure,absorb(snap.agentProgress()),tick,publishToevery 5 ms; a second thread loopspollAgent(rep.snap, rep.player)every 1250 us (four polls per UI tick, standing in for fast-forward) and writes therightNow/upNowatomics and the per-poll vector. Assertions identical to inline:pressedB == 9,rec == {1,1,1,0,0,0,1,1,1,0,0,0,1,1,1}(compared with the vector the inline run produced, run both in one process:loopbackRun(false)first, keep its vector,CHECK(thr == inl)),rightNowfalse after the server drops,upNowfalse after hardcore. Run: expected FAIL (neuro_on_threaded: the run prints[neuro] off for this run;neuro_watch_threaded: no state line).cmake --build build -j4 && ctest --test-dir build -R "neuro_on_threaded|neuro_watch_threaded|neuro_loopback_threaded" --output-on-failure -
Step 2: lift the gate.
neuro_app.cpp: deleteif (app.emuThreadThisRun()) s.enabled = false;(:52) and its comment line in the header ofsettingsFrom("and the emulation thread" -> removed); delete theneuroInitlog block (:111-112); delete the hint (:171). Rewrite the three stale comments:neuro_agent.hthreads paragraph -> "tick,configure,publishToandabsorbrun on the UI thread; the buttons are played byAgentPlayeron the thread that polls input (the emulation thread when the app runs one), fed through InputSnapshot; only the WebSocket worker runs elsewhere, behind WsClient's queues.";neuro_actions.h:15-17 -> "...InputQueue turns accepted actions into a program the app's input poll plays one ControlSet per emulated frame (AgentPlayer, pollAgent) and ORs into Player 1";neuro_app.cppheader unchanged otherwise. -
Step 3: the threaded headless loop ticks Neuro in lock step. In app.cpp's
if (threaded())branch (:2534), add a third case afterach.mockMode(): when!neuroCliUrl.empty():for (unsigned i = 0; i < a.frames; i++) { // one frame at a time, in real time, like the inline --neuro loop headlessNeuroFrame(hlT0, i); // neuroTick (+ syncDebug), then waits for frame i's time emu.advance(1); // the replica runs in the core's poll on the emulation thread if ((i & 15) == 15) emu.call([this] { audio.clear(); }); host.pinSnapshot(); for (const OsdMessage& m : host.takeMessages()) std::fprintf(stderr, "[osd] %s\n", m.text.c_str()); }and after the
host.pinSnapshot();that follows the branch (:2556) callneuroHeadlessReport(*this)for both modes (remove C2's inline-only call). The emulation thread fills the watch view after each frame (emuRunOneFrame->fillDebugViews, 3a), the serial moves viasyncDebug()insideheadlessNeuroFrame. -
Step 4: run, expect PASS.
cmake --build build -j4 && ctest --test-dir build -R "neuro|emu_thread_e2e|input_snapshot" --output-on-failureExpected:
neuro_unit,neuro_loopback,neuro_loopback_threaded,neuro_app_inline,neuro_on_threaded(log shows30 frames of input playedandemulation thread)),neuro_watch_inline,neuro_watch_threaded,neuro_core_e2ePASS; noneuro_off_threaded. -
Step 5: full suite and ThreadSanitizer.
ctest --test-dir build -j4 --output-on-failure env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build-tsan -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVJ_APP_TSAN=ON cmake --build build-tsan -j4 && ctest --test-dir build-tsan -L emu_thread --output-on-failureExpected: all PASS,
tsan.suppunchanged (git diff --stat app/tests/tsan.suppempty). The reports to expect if something is wrong:AgentPlayer/InputQueuetouched from the UI thread (it is emulation-only), orGameStateread racingneuroWatchBytes(UI-only). Neither exists in this design. -
Step 6: docs.
docs/neuro.md:30-34, replace the "Emulation thread" paragraph with: "Emulation thread: Neuro works with Settings > Interface > Emulation thread on or off, and plays the same either way. The UI thread keeps the connection and the list of accepted moves and hands that program to the emulation thread through the input snapshot; the core's input poll plays it one step per emulated frame, sopress_buttons frames=3is three emulated frames at any display rate and under fast-forward, never three UI frames. Game profile values (watch) are copied from RAM by the emulation thread after each frame (about 10 times a second) and read from there, so the state text can be up to a tenth of a second old; a profile whose watches lie more than 64 KiB apart makes the app copy the whole 2 MB of RAM at that rate while Neuro is connected. Menus, a mouse on Player 1, hardcore and a dropped connection let go of every button within one emulated frame."docs/neuro.md:130-131:ctest -R neurolist -> "neuro_unit,neuro_loopbackandneuro_loopback_threaded(a WebSocket server on 127.0.0.1; the second polls the buttons from another thread),neuro_core_e2e(the real core's joypad matrix, inline and threaded),neuro_app_inline/neuro_on_threaded(the app headless against that server) andneuro_watch_*(a profile watch read back from RAM)."app/README.md:850-853: delete nothing; append to the bullet list "- Works with the emulation thread on or off: the buttons are played on the thread that polls input (see docs/neuro.md)."docs/agent/testing.md: rename theneuro_off_threadedentry (grep it; if absent, add the line under the emu_thread list) to the three threaded names.VJNeuroTests.cmakeheader comment: replace theneuro_off_threadedline by the six lines of the new tests.app/src/input.h,input.cpp,app.cpp:476comments are already gone with C2;grep -rn "agentControls\|off for this run\|neuro_off_threaded" app docsmust be empty exceptdocs/app-emulation-thread-plan-phase1.md(historical; do not edit).
-
Step 7: commit.
git add app docs/neuro.md git commit -m "feat(app): Neuro works with the emulation thread on (#967) Co-Authored-By: Claude Opus 5.5 <[email protected]>" -
Step 8: Studio check (windowed; only on the Studio). After
uptimeon the Studio shows a load under 2 (ssh studio-runner uptime):VJR=~/Workspace/Provenance/vj-eval/remote/vjr.sh bash $VJR run p3c-neuro env -u DEVELOPER_DIR bash -lc 'VJ_WINDOWED_OK=1 bash app/tests/emu_thread_windowed_check.sh build/virtualjaguar "$VJ_ROMS/yarc.j64" out/p3c'Expected: unchanged results (no
owner violation). If Randy (neuro-sdk/Randy,npm start) is present on the Studio, also runbuild/virtualjaguar --emu-thread on --neuro ws://localhost:8000 --perf-frames 1800 --show-window settings-neuro "$VJ_ROMS/yarc.j64"and grep its log:[neuro]connect line present, noowner violation, noThreadSanitizer. Record both results for the PR body; skip Randy (say so) when it is not installed, the G1 gate covers it. -
Step 9: PR (not merged by the agent).
git push -u libretro feat/967-emu-thread-phase3c gh pr create --repo libretro/virtualjaguar-libretro --base develop --head feat/967-emu-thread-phase3c \ --title "feat(app): Neuro on the emulation thread (#967 phase 3c)" --body-file ~/Workspace/Provenance/vj-eval/remote/emu-p3c/pr-body.mdBody (write it to that file first): what changed (the program/replica design,
lineage/firstFrame, watches as aDebugViewsrequest, gate and hint removed); the Review Focus 3 and #1025 pins by test name (the table above); the Studio results;Refs #967; ends with🤖 Generated with [Claude Code](https://claude.com/claude-code). Expected: allApp *jobs andThreadSanitizer (emulation thread)green. After merge remove the worktree:git worktree remove ~/Workspace/Provenance/vj-wt/emu-p3c.
Gate G1: nothing is switched off
Runs on libretro/develop after 3a, 3b and 3c have all merged and before 3d's PR opens. It is the maintainer's rollout bar (design §1 table, amended 2026-10-16: nightlies default On "once nothing is switched off while it runs"), turned into one mechanical check that stays in CI, a TSan pass, a Studio windowed run with every feature on at once, and a whole-branch architecture review by a model that has not seen the work. Work happens on the 3d branch (feat/967-emu-thread-phase3d, worktree ~/Workspace/Provenance/vj-wt/emu-p3d); G1a and G1b are its first two commits and G1c-G1d run on that head. A G1 finding is fixed in its own PR to develop (never inside 3d) and the 3d branch is rebased; 3d's flip commits are written only after G1 is green.
<pre-3a> below is abc9054e (develop when this plan was written). If unrelated app/ commits landed after it, use the parent of 3a's merge commit instead: git log --first-parent --oneline libretro/develop -- app/src/debug/debug_core.h | tail -1, then ^1 of that.
Task G1a: the mechanical bar (no_threaded_limits)
Files:
- Create:
app/tests/no_threaded_limits.sh,app/tests/threaded_branch_allowlist.txt - Modify:
app/cmake/VJEmuThreadTests.cmake(append afteremu_thread_start_fallback, as ofabc9054e~:177; and update the header comment list: delete the*_threaded limitsline)
Interfaces:
-
Consumes: the merged 3a-3c tree (the symbols and test names removed or renamed there).
-
Produces: ctest
no_threaded_limits(labelemu_thread; registered only when abashexists, so the MSVC row skips it). -
Step 1: Branch and check that 3a-3c are all in.
git -C ~/Workspace/Provenance/virtualjaguar-libretro fetch libretro
git -C ~/Workspace/Provenance/virtualjaguar-libretro worktree add ~/Workspace/Provenance/vj-wt/emu-p3d -b feat/967-emu-thread-phase3d libretro/develop
cd ~/Workspace/Provenance/vj-wt/emu-p3d
for f in app/src/debug/debug_views.h app/src/debug/debug_core.h; do test -f "$f" || echo "MISSING $f (3a/3b)"; done
/usr/bin/grep -c 'AgentPlayer' app/src/neuro/neuro_actions.h # 3c
Expected: no MISSING line and a count of at least 1. Anything else: stop, 3d does not start.
- Step 2: Write the script. The greps use
GREP=/usr/bin/grep(never the shell wrapper);badprints the offending lines so a failure names its fix.
#!/usr/bin/env bash
# The Phase 3 bar (#967): with the emulation thread on, NOTHING is switched off. usage: no_threaded_limits.sh <repo-root>
# 1 the gates and their symbols are gone 2 the user-facing "off while threaded" texts are gone 3 the old limit
# tests are gone and their replacements exist 4 no gdb option lock in app.cpp 5 only listed places ask "am I
# threaded?" (threaded_branch_allowlist.txt: each picks HOW something runs, never WHETHER) 6 tsan.supp did not grow
set -u
root="${1:?repo root}"; cd "$root" || exit 2
G="${GREP:-/usr/bin/grep}"; fail=0
bad() { echo "FAIL no_threaded_limits: $1"; shift; printf ' %s\n' "$@"; fail=1; }
X=(--exclude=no_threaded_limits.sh --exclude=threaded_branch_allowlist.txt)
h=$("$G" -rnE 'limitFeaturesForEmuThread|liftFeatureLimits|setThreadBlocked|threadBlocked|setHaltAvailable|haltDisabledReason' app/src app/tests app/cmake "${X[@]}")
[ -z "$h" ] || bad "a removed gate is referenced (delete it):" "$h"
h=$("$G" -rnE 'are off while the emulation thread is on|back on \(the emulation thread did not start\)|off for this run: the emulation thread|Off while the emulation thread is on|[Nn]ot available while Settings > Interface > Emulation thread|this version turns off the Tools|Halting is not available with the emulation thread' app/src app/tests app/cmake app/README.md docs/neuro.md docs/app-debug-tools.md "${X[@]}")
[ -z "$h" ] || bad "an 'off while threaded' text survives:" "$h"
h=$("$G" -rnE 'emu_thread_limits|gdb_locked_threaded|neuro_off_threaded' app/cmake app/tests docs/agent docs/neuro.md "${X[@]}")
[ -z "$h" ] || bad "a limit test is still named (delete or rename it):" "$h"
# What 3a (A9), 3b (B4, B5) and 3c (C4) register in place of the three limit tests.
for t in debug_views_threaded debug_on_threaded gdb_enabled_threaded gdb_hardcore_locked_on debug_e2e_threaded gdb_threaded_halt neuro_on_threaded neuro_loopback_threaded; do
"$G" -rqE "NAME[[:space:]]+$t([[:space:]]|\))" app/cmake || bad "replacement test not registered: $t"
done
h=$(awk '/virtualjaguar_gdb_stub/{n=4} n>0{if ($0 ~ /= *"disabled"/) print FILENAME":"FNR": "$0; n--}' app/src/app.cpp)
[ -z "$h" ] || bad "app.cpp locks the GDB stub option (only achievements*.cpp may, under hardcore):" "$h"
P='emuThreadThisRun|(\.|->)threaded\(\)'
while read -r n f; do
a=$(awk -v f="$f" '$1==f && $1 !~ /^#/ {print $2}' app/tests/threaded_branch_allowlist.txt); a=${a:-0}
if [ "$n" -gt "$a" ]; then
bad "$f asks 'am I threaded?' $n times, allowed $a (a feature must not be disabled by it; if it only picks HOW, raise the count with a reason):" \
"$("$G" -nE "$P" "$f")"
elif [ "$n" -lt "$a" ]; then echo "note: $f allows $a but has $n; tighten the allowlist"; fi
done < <("$G" -rE "$P" app/src --exclude='emu_thread.*' | cut -d: -f1 | sort | uniq -c | awk '{print $1, $2}')
n=$(awk '!/^[[:space:]]*(#|$)/' app/tests/tsan.supp | wc -l)
[ "$n" -le 1 ] || bad "app/tests/tsan.supp grew to $n entries (route the reader through a published value instead)"
[ "$fail" = 0 ] && echo "no_threaded_limits: OK"
exit "$fail"
threaded_branch_allowlist.txt is <file> <max count> # why, one line per file. Starting content to be regenerated in Step 4:
# Files that may ask "am I threaded?" (emuThreadThisRun, x.threaded()), with the most hits each. Every hit selects HOW
# something runs on the emulation thread or the UI thread; none may disable a feature (#967 Phase 3 bar).
app/src/app.cpp 0 # run(): loop choice; wireHost(): emuInput_/emuFastForward_ when threaded; start/stopEmuThread
app/src/netplay_ui.cpp 1 # self-test step: advance(1) + pinSnapshot instead of the inline update
app/src/netplay.cpp 1 # bounce count: one more emulated frame when threaded
app/src/achievements.cpp 2 # readMemory fallback guard; rc_client_idle runs on the emulation thread
app/src/ui.cpp 1 # "Restart the app to apply" (wanted != this run)
- Step 3: Register it and watch it fail on the old tree.
# The Phase 3 bar (#967): nothing is switched off while the emulation thread is on. A static check, so it also keeps
# the bar from coming back (docs/agent/testing.md).
find_program(VJ_BASH bash)
if(VJ_BASH AND NOT MSVC)
add_test(NAME no_threaded_limits COMMAND ${VJ_BASH} "${CMAKE_CURRENT_LIST_DIR}/../tests/no_threaded_limits.sh" "${CMAKE_CURRENT_LIST_DIR}/../..")
set_tests_properties(no_threaded_limits PROPERTIES TIMEOUT 60 LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL ")
endif()
Prove the script bites by running it on a copy of the pre-3a tree:
S=$(mktemp -d) && git archive abc9054e app | tar -x -C "$S" \
&& command cp app/tests/no_threaded_limits.sh app/tests/threaded_branch_allowlist.txt "$S/app/tests/" \
&& bash app/tests/no_threaded_limits.sh "$S"; echo "rc=$?"
Expected: rc=1 with FAIL lines naming limitFeaturesForEmuThread, setThreadBlocked, Not available while Settings > Interface > Emulation thread, neuro_off_threaded, app.cpp locks the GDB stub option, and app/src/neuro/neuro_app.cpp asks 'am I threaded?' 3 times, allowed 0 and app/src/debug/op_ui.cpp ... 1 times, allowed 0 (the two disabling sites 3a/3c remove).
- Step 4: Run it on the merged tree and regenerate the allowlist.
bash app/tests/no_threaded_limits.sh "$PWD"; echo "rc=$?"
/usr/bin/grep -rnE 'emuThreadThisRun|(\.|->)threaded\(\)' app/src --exclude='emu_thread.*'
Expected on the first run: rc=1, with only the count line for app/src/app.cpp (0 in the starting file). Read EVERY line the second command prints. Each must answer "HOW" (which mechanism runs: host.threaded() ? emuFastForward_ : fastForward, the if (!emu.threaded()) return; in stopEmuThread, the run decision) and none may disable, grey out, hide, refuse or lock anything because the thread is on. A line that does is a Phase 3 bug: stop, file the fix against develop. Then set the counts to the actual numbers, keep one # why per file, run again.
Expected: no_threaded_limits: OK, rc=0. Then env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && ctest --test-dir build -R '^no_threaded_limits$' --output-on-failure: 1/1 Test passed.
- Step 5: Commit.
git add app/tests/no_threaded_limits.sh app/tests/threaded_branch_allowlist.txt app/cmake/VJEmuThreadTests.cmake
git commit -F - <<'EOF'
test(app): no_threaded_limits keeps every feature on while the emulation thread runs (#967)
Fails when a threaded-only gate, its user-facing text or its limit test comes back, when a file starts asking
"am I threaded?" without being listed, or when tsan.supp grows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task G1b: the Studio drivers (--show-window tools, --perf-halt) and the gate script
The gate needs every tool open and an F9 halt in a run nobody is at the keyboard for. Test-support code, verified by the gate itself.
Files:
- Modify:
app/src/main.cpp:197and the usage text at :27-33 (flag--perf-halt,--show-window tools),app/src/app.h:104(bool perfHalt = false;besideperfPause),app/src/app.cpp:2021(thetoolsbranch, in the--show-windowchain) and:2077(beside theperfPauseline) - Create:
app/tests/emu_thread_gate_check.sh,app/tests/emu_thread_gate_gdb.py,app/tests/neuro_gate_server.py
Interfaces:
-
Consumes:
openDebugWindow(DebugSession&, const std::string&)(debug_ui.cpp:123),DebugSession::findTool(id)->isOpen(),DebugSession::breakNow()/resume()/halted()/enabled()(3b mirror),perf.seen(the loop's UI iteration counter, app.cpp:2077). -
Produces: log lines the gate asserts on, exactly:
[app] tool <id> open=<0|1>formemory palette scanner cpu objects blitter textures scope, once at start and once again while halted, the second time suffixed(halted);[perf] halt requested,[perf] halted=<0|1>,[perf] halt unavailable(whendebug.enabled()is false),[perf] resumed.
-
Step 1: Implement the flags.
--show-window tools: in the chain at app.cpp:2021 addelse if (a.showWindow == "tools") openAllTools("");whereopenAllTools(const char* suffix)is a lambda inrunInteractivelooping the eight ids above:openDebugWindow(debug, id)thenfprintf(stderr, "[app] tool %s open=%d%s\n", id, tool && tool->isOpen && tool->isOpen(), suffix). The!emuThreadThisRun_ &&guard on the oldopenDebugWindowline is already gone in 3a; this branch must not reintroduce it.--perf-halt, next to theperfPauseline: atperf.seen == 120: ifdebug.enabled()thendebug.breakNow()and log[perf] halt requested, else log[perf] halt unavailable; at== 240:openAllTools(" (halted)")and log[perf] halted=%dfromdebug.halted(); at== 420:debug.resume()and log[perf] resumed. These are the calls F9 makes (app.cpp:1654-1657), nothing else.- Usage text:
[--perf-halt]after[--perf-pause];--show-window memory|...|scope|tools.
-
Step 2: Write the gate script
app/tests/emu_thread_gate_check.sh <virtualjaguar> <rom> <out-dir>in the style ofemu_thread_windowed_check.sh(sameVJ_WINDOWED_OK=1refusal, samefailaccumulator, printsemu_thread_gate_check: OK). Every run greps its log forowner violation|ThreadSanitizer|WARNING: ThreadSanitizer(any hit fails). Three runs, all--emu-thread on:Run A, tools + F9 halt + Neuro.
python3 app/tests/neuro_gate_server.py 22350 "$out/neuro.txt" &first, thenVJ_PERF_CSV="$out/a.csv" "$exe" --emu-thread on --perf-frames 720 --perf-input --perf-halt --show-window tools --neuro ws://127.0.0.1:22350 "$rom". Assert: eight[app] tool <id> open=1lines and eightopen=1 (halted)lines;[perf] halted=1and[perf] resumed, nohalt unavailable;$out/neuro.txtends withok presses=<n>where n >= 5. CSV (columnsiter_ms,emu_frames,..., one row per UI iteration, header skipped): rows 130-410 all haveemu_frames==0(the emulation is parked), their medianiter_ms<= 17.5 and p99 <= 25 (60 Hz Studio display;GATE_MEDIAN_MS/GATE_P99_MSoverride for another refresh), and rows 440-720 haveemu_frames>0in at least 200 of 280 (it ran again after Continue). The halted-rows criterion is "the window keeps presenting at display rate while halted".Run B, gdb halt with the windows open. Launch the app in the background with
--option virtualjaguar_gdb_stub=enabled --option virtualjaguar_gdb_wait=enabled --option virtualjaguar_gdb_port=22349 --perf-frames 900 --perf-input --show-window toolsandVJ_PERF_CSV="$out/b.csv", thenpython3 app/tests/emu_thread_gate_gdb.py 22349 5.0(below), thenwaitfor the app. Assert: probe exit 0; app exit 0 within 20 s; eightopen=1; the longest run of consecutiveemu_frames==0rows inb.csvis >= 200 and, after dropping its first 30 rows (the load), has median <= 17.5 and p99 <= 25; andemu_frames>0in at least 100 rows after it.Run C, plain. Same flags as the existing windowed check's
onrun plus--show-window tools, 600 frames, to prove the new default path presents with every tool open and nothing halted: >= 250 rows withemu_frames>0.emu_thread_gate_gdb.py <port> <hold-seconds>:sys.path.insert(0, <repo>/test/tools)andimport gdb_breakpoint_probe as pforcksum/pack/recv_packet/cmd. Connect (retry 100 x 0.1 s), send+,qSupported,QStartNoAckMode; thevirtualjaguar_gdb_waithalt means the 68K is stopped at its first instruction, so:g(registers, length > 0),m4,8(reply is 16 hex digits),Z0,1000,2thenz0,1000,2(bothOK); sleep<hold-seconds>printingholding halt;D(detach, which also clears breakpoints) orc; exit 0 printinggdb gate: OK. Any non-OKreply exits 1 with the packet.neuro_gate_server.py <port> <result-file>: stdlib only (socket,hashlib,base64,json,struct). Accept one connection; RFC 6455 handshake (Sec-WebSocket-Accept= base64(sha1(key + magic))); read masked client frames; after the client'sstartupmessage and itsactions/register, every 1 s for 10 s send an unmasked text frame{"command":"action","data":{"id":"g<N>","name":"press_buttons","data":"{\"buttons\":[\"a\"],\"frames\":3}"}}; countaction/resultreplies with"success":true; at the end writeok presses=<n>(orFAIL presses=<n>) to the result file and exit. Randy is not used: the Studio hasnodebut no neuro-sdk checkout and the gate must not fetch code. -
Step 3: Check it compiles and refuses to run here.
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j4 --target virtualjaguar 2>&1 | tail -2
bash app/tests/emu_thread_gate_check.sh build/virtualjaguar test/roms/yarc.j64 /tmp/none; echo "rc=$?"
python3 -I -c "import ast,sys; [ast.parse(open(f).read()) for f in sys.argv[1:]]" app/tests/emu_thread_gate_gdb.py app/tests/neuro_gate_server.py
Expected: build ok; refusing: this opens a window; set VJ_WINDOWED_OK=1 on the measurement machine and rc=2; no syntax error. Do not set VJ_WINDOWED_OK on this Mac.
- Step 4: Commit.
git add app/src/main.cpp app/src/app.h app/src/app.cpp app/tests/emu_thread_gate_check.sh app/tests/emu_thread_gate_gdb.py app/tests/neuro_gate_server.py
git commit -F - <<'EOF'
test(app): Studio gate for the emulation thread with every feature on (#967)
--show-window tools opens all eight Tools windows, --perf-halt does F9's break and continue at fixed UI frames,
and emu_thread_gate_check.sh asserts no owner violation, a presenting UI while halted by F9 and by gdb, and a
connected Neuro client, all with --emu-thread on.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task G1c: full suite, TSan, Studio windowed, evenness
No repository changes; results go into the PR body (Task D3). Windowed runs on the Studio only, after uptime shows a load under 2 (the Studio's ghrunner jobs push it to 20-100 without warning: wait and re-check before every arm). A sleeping display blocks the vsync swap forever: every windowed command below starts with caffeinate -u -t 5.
Studio notes (memory: reference_lan_build_runner; Phase 2 Task 11): vjr.sh run %q-quotes each argument and exports DEVELOPER_DIR=CommandLineTools, which breaks the app's CMake, so wrap every command as bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc '<script>'. ~/vj-remote/even-run.sh already exists on the Studio.
- Step 1: Ship and build both trees.
VJR=~/Workspace/Provenance/vj-eval/remote/vjr.sh
bash $VJR status # load first; do not push while it is above 10
bash $VJR push emu-p3d HEAD
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'uptime; cmake -S app -B app/build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build app/build -j14 2>&1 | tail -1'
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'cmake -S app -B app/build-tsan -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ >/dev/null && cmake --build app/build-tsan -j14 2>&1 | tail -1'
Expected: both build. (.github/workflows/app.yml tsan job, ~:213-224, is the recipe: -DVJ_APP_TSAN=ON -DVJ_APP_LTO=OFF, clang, RelWithDebInfo.)
- Step 2: Full suite, then the label, Release.
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'ctest --test-dir app/build -C Release -E shaders_gl -j6 2>&1 | tail -6; ctest --test-dir app/build -C Release -L emu_thread -j1 2>&1 | tail -4; /usr/bin/grep -c "owner violation" app/build/Testing/Temporary/LastTest.log || true'
Expected: 100% tests passed for both ctest runs (the -L emu_thread run includes no_threaded_limits, debug_views_threaded, debug_e2e_threaded, gdb_threaded_halt, neuro_on_threaded, neuro_loopback_threaded) and a 0 from the grep. A netplay "no link within 30s" under load is load: re-run those alone first.
- Step 3: The label under ThreadSanitizer, as CI runs it, plus macOS's abort setting.
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'TSAN_OPTIONS="halt_on_error=1 abort_on_error=0 second_deadlock_stack=1 suppressions=$PWD/app/tests/tsan.supp" ctest --test-dir app/build-tsan -C RelWithDebInfo -L emu_thread --output-on-failure 2>&1 | tail -8'
Expected: 100% tests passed (tsan_canary passes by failing, as designed) and no ThreadSanitizer text. A report is fixed by routing the reader through a published value (never tsan.supp; no_threaded_limits enforces it).
- Step 4: Windowed gate, Release build.
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'uptime; caffeinate -u -t 5; VJ_WINDOWED_OK=1 bash app/tests/emu_thread_gate_check.sh app/build/virtualjaguar test/roms/yarc.j64 $HOME/vj-remote/out/p3d-gate'
Expected: emu_thread_gate_check: OK. Keep the printed halted-row median / p99, the gdb-hold run's, and the Neuro presses= in the PR table.
- Step 5: Windowed gate, TSan build.
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'uptime; caffeinate -u -t 5; TSAN_OPTIONS="halt_on_error=1 abort_on_error=0 second_deadlock_stack=1 suppressions=$PWD/app/tests/tsan.supp" GATE_MEDIAN_MS=40 GATE_P99_MS=80 VJ_WINDOWED_OK=1 bash app/tests/emu_thread_gate_check.sh app/build-tsan/virtualjaguar test/roms/yarc.j64 $HOME/vj-remote/out/p3d-gate-tsan'
Expected: OK with no ThreadSanitizer line (the display-rate bounds are loosened: an instrumented build is several times slower; the point of this run is the report). Free-running windows are the only place the pump, the copier, the UI's request edits and Neuro's replica run concurrently for seconds.
- Step 6: Evenness A/B/B/A with the Memory window open. A = inline (
off), B = threaded with the late latch (latch). 3600 UI frames each, yarc at 60 Hz, the Memory window open in all four so the copier's per-frame cost is in B.even-run.shgates on load < 2 itself.
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'ROMS=test/roms/yarc.j64 EXTRA="--show-window memory" FRAMES=3600 bash ~/vj-remote/even-run.sh p3d off latch latch off'
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'ROMS=test/roms/yarc.j64 FRAMES=3600 bash ~/vj-remote/even-run.sh p3d-nowin latch'
bash $VJR run emu-p3d env -u DEVELOPER_DIR bash -lc 'for p in $HOME/vj-remote/out/even-p3d-yarc*-[1-4]-*.csv $HOME/vj-remote/out/even-p3d-nowin-yarc*.csv; do echo "== $p"; python3 app/tests/perf_trace_summary.py "${p%.csv}"; done'
Expected (the #1018 numbers for yarc, threaded, 60 Hz: 12 repeats + 31.5 skips per minute): the two B runs within their own run-to-run spread of each other and of 12 + 31.5/min; B's emulation-thread frame cost (VJ_PERF_TRACE end - start) with the window open versus the no-window arm is the copier's cost. A larger cost or worse evenness goes into the PR as a finding with its numbers; it is reported, not tuned, in this task.
Task G1d: whole-branch architecture review (Fable)
No repository changes. A model that did not write 3a-3c reads the merged code against the design and tries to break it. The findings go into 3d's PR body.
- Step 1: Collect the inputs.
cd ~/Workspace/Provenance/vj-wt/emu-p3d
git diff --stat abc9054e..HEAD -- app/ | tail -3 # <pre-3a> as above
git diff abc9054e..HEAD -- app/ > "$(mktemp -d)/phase3.diff" # note the path it prints below for the prompt
- Step 2: Dispatch it with the Agent tool:
subagent_type: general-purpose,model: fable(not a fork, not Opus, not given any conclusion of this plan), prompt:
You are reviewing the merged Phase 3 work of the "emulation thread" feature of a desktop Atari Jaguar emulator
(C++17 app/, a single-threaded C89 core that must not change). Repo: ~/Workspace/Provenance/vj-wt/emu-p3d (read-only:
do not edit, build, run tests or launch anything; use /usr/bin/grep, not `grep`). The change under review is
`git diff abc9054e..HEAD -- app/` (3a: debug views, 3b: halt + gdb stub, 3c: Neuro). The spec is
docs/app-emulation-thread-design.md sections 2, 5, 6, 8 and its 2026-10-20 amendments, plus docs/neuro.md
("Emulation thread").
The invariant: with Settings > Interface > Emulation thread on, only the emulation thread calls into the core
(every retro_* call, every vj_debug_iface entry point, every read or write of core memory), and nothing is switched
off. Find every way the merged code breaks it or the spec. Specifically:
1. Every call site of vj_debug_iface members, host.memoryRegion, retro_get_memory_data and DebugCore methods: which
thread can reach each? Anything reachable from the UI thread without DebugSession::coreCall/corePost or
EmuThread::debugCall is a finding.
2. Every field read by one thread and written by the other (App, DebugSession, DebugCore, DebugViews, InputSnapshot,
neuro::Agent): is it atomic, mutex-guarded, or published through a snapshot? List the ones that are not.
3. Locks: draw the acquisition order of EmuCommands::mu_, DebugViews' mutex, DebugCore's halt mutex, InputSnapshot's
and the config/option locks. Is a lock ever held across a vj_debug_iface call, a condition-variable wait, a
debugCall, or the halt pump? Can the UI wait (debugCall / call) on a thread that is waiting on the UI?
4. The halt: the pump must return within a bounded wait so the core's GDBHalt() loop (src/debug/gdbtarget.c) keeps
servicing its socket and halt timeout. Check shutdown while halted (quit while halted by F9 and by gdb: does the
join finish well inside 3 s?), the Lane::Frame vs Lane::Debug drain placement (frame lane must never be drained
inside the pump), and every EmuThread::call that can queue behind a halted frame (the give-up predicate: look for
a window between the "cancelled" check and the task starting).
5. Content switches: DebugViews buffers and requests across a game reload, the per-core cry table, the OP snapshot,
scanner state and Neuro watches. Can a window draw bytes from the previous game?
6. Neuro: AgentPlayer must press for exactly N emulated frames regardless of display rate and fast-forward; check
generation handling (a stale program replacing a newer one, or the reverse), hardcore (the program must be
emptied), link drops, and anything left of the old agentControls path.
7. Leftovers: any remaining code that disables, hides or refuses a feature because the thread is on (grep for
emuThreadThisRun, threaded(), "this version", "while the emulation thread"), and any inline-only path that is
reachable threaded.
8. Divergences from the design document's text, including where the design is silent and the code made a choice.
Report: a table of findings, each with severity (blocker / major / minor), file:line, what is wrong, and the
sequence of events that triggers it. Then a separate list of what you checked and found correct, saying how you
checked it. Do not pad: no style comments, no praise.
- Step 3: Triage the report. A blocker or major with a concrete trigger sequence is reproduced by a test or by reading the cited lines, then fixed in a PR to develop (or filed as an issue, if the maintainer defers it in the PR) before 3d's PR opens; never silently dropped. Minors become issues. The table (finding, severity, disposition, PR or issue number) goes verbatim into 3d's PR body (Task D3, Step 7). If the review is clean, say that and keep its list of what was checked.
Sub-phase 3d: nightly builds default the emulation thread On
Branch feat/967-emu-thread-phase3d, worktree ~/Workspace/Provenance/vj-wt/emu-p3d (created in G1a), merged last, after 3a, 3b, 3c and a green G1. It adds the build channel, the default rule (a stored choice always wins), the Settings and About text, the start-up log lines, the workflow and flatpak plumbing, one nightly-channel CI configure, and docs. The setting stays Off in releases and in dev builds (PR artifacts, local builds); headless runs stay inline. Reverting this PR alone restores Off everywhere. No file under src/ or libretro*.c changes.
Task D1: the channel, the default rule, emuThreadWanted
Files:
- Create:
app/src/build_channel.h,app/tests/emu_thread_default_test.cpp - Modify:
app/CMakeLists.txt(the cache variable and an INTERFACE targetvj_build_channelafter theVJ_DISCORD_APP_IDblock, as ofabc9054e~:72-76; link it intovirtualjaguarin the block at ~:457 that definesVJ_APP_VERSION),app/src/app.cpp:282(emuThreadWanted),app/src/app.h:245-249(comment),app/cmake/VJEmuThreadTests.cmake
Interfaces:
-
Consumes:
ui.emu_threadread at app.cpp:211 (emuThreadPref-1/0/1). -
Produces:
build_channel.hexactly as in the Interfaces section (kBuildChannel,kEmuThreadDefault,emuThreadDefaultFor,emuThreadEffective), with#include <string_view>and, at the end of the namespace,static_assert(kEmuThreadDefault == emuThreadDefaultFor(kBuildChannel), "VJ_EMU_THREAD_DEFAULT disagrees with VJ_BUILD_CHANNEL (app/CMakeLists.txt)");so any CMake drift fails the compile. CMake targetvj_build_channel(INTERFACE) carryingVJ_BUILD_CHANNEL="<channel>"andVJ_EMU_THREAD_DEFAULT=<1|0>; ctestemu_thread_default_unit. -
Step 1: Write the failing test
app/tests/emu_thread_default_test.cpp(sameCHECK/FAILstyle asemu_commands_test.cpp; no SDL, no core):
// "pref 0 is Off and pref 1 is On in every channel; only pref -1 follows the channel" (Review Focus 4)
for (const char* ch : {"release", "nightly", "dev"}) {
const bool def = emuThreadDefaultFor(ch);
CHECK(emuThreadEffective(0, def) == false);
CHECK(emuThreadEffective(1, def) == true);
CHECK(emuThreadEffective(-1, def) == def);
}
CHECK(emuThreadDefaultFor("nightly") && !emuThreadDefaultFor("release") && !emuThreadDefaultFor("dev"));
CHECK(!emuThreadDefaultFor("") && !emuThreadDefaultFor("Nightly") && !emuThreadDefaultFor("nightlies"));
CHECK(emuThreadEffective(-5, true) && !emuThreadEffective(-5, false)); // any negative is "never chosen"
CHECK(!emuThreadEffective(2, true)); // only 1 is On
const std::string_view c = kBuildChannel; // what THIS build was configured with
CHECK(c == "release" || c == "nightly" || c == "dev");
CHECK(kEmuThreadDefault == (c == "nightly"));
Register (after late_latch_unit's block; the target links vj_build_channel so it sees the same defines as the app):
add_executable(vj_emu_thread_default_test tests/emu_thread_default_test.cpp)
target_include_directories(vj_emu_thread_default_test PRIVATE src)
target_link_libraries(vj_emu_thread_default_test PRIVATE vj_build_channel)
vj_emu_thread_warnings(vj_emu_thread_default_test)
add_test(NAME emu_thread_default_unit COMMAND vj_emu_thread_default_test)
set_tests_properties(emu_thread_default_unit PROPERTIES TIMEOUT 60 LABELS emu_thread FAIL_REGULAR_EXPRESSION "FAIL ")
- Step 2: Run it, expect FAIL.
cmake -S app -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -3 (use the full configure line from the plan header). Expected: the configure still succeeds (CMake treats the unknown vj_build_channel as a plain library name), then cmake --build build -j4 --target vj_emu_thread_default_test fails with 'build_channel.h' file not found.
- Step 3: Implement. In
app/CMakeLists.txt:
set(VJ_BUILD_CHANNEL "dev" CACHE STRING "release | nightly | dev: the build channel (nightlies default the emulation thread on, #967)")
set_property(CACHE VJ_BUILD_CHANNEL PROPERTY STRINGS release nightly dev)
if(NOT VJ_BUILD_CHANNEL MATCHES "^(release|nightly|dev)$")
message(FATAL_ERROR "VJ_BUILD_CHANNEL must be release, nightly or dev (got '${VJ_BUILD_CHANNEL}')")
endif()
set(VJ_EMU_THREAD_DEFAULT_VALUE 0)
if(VJ_BUILD_CHANNEL STREQUAL "nightly")
set(VJ_EMU_THREAD_DEFAULT_VALUE 1)
endif()
add_library(vj_build_channel INTERFACE)
target_compile_definitions(vj_build_channel INTERFACE VJ_BUILD_CHANNEL="${VJ_BUILD_CHANNEL}" VJ_EMU_THREAD_DEFAULT=${VJ_EMU_THREAD_DEFAULT_VALUE})
(the check and the cache variable go before the first fetched dependency, so a bad value fails in under a second), and target_link_libraries(virtualjaguar PRIVATE vj_build_channel) in the virtualjaguar block. Write build_channel.h per the Interfaces section. Then:
// app.cpp:282, replacing the one-liner:
bool App::emuThreadWanted() const { return emuThreadEffective(emuThreadPref, kEmuThreadDefault); }
(#include "build_channel.h" in app.cpp; update the app.h:245-249 comment to "-1 = never chosen: follows the build channel (nightly on, release and dev off)"; delete the "Phase 3's default" remark on the saveConfig line at app.cpp:249).
- Step 4: Run, expect PASS, for all three channels.
cmake --build build -j4 --target vj_emu_thread_default_test virtualjaguar && ctest --test-dir build -R '^emu_thread_default_unit$' --output-on-failure
for c in release nightly; do
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build-$c -DCMAKE_BUILD_TYPE=Release -DVJ_BUILD_CHANNEL=$c >/dev/null \
&& cmake --build build-$c -j4 --target vj_emu_thread_default_test && ctest --test-dir build-$c -R '^emu_thread_default_unit$' --output-on-failure
done
cmake -S app -B build-bogus -DVJ_BUILD_CHANNEL=bogus 2>&1 | /usr/bin/grep 'VJ_BUILD_CHANNEL must be'; echo "rc=${PIPESTATUS[0]}"
Expected: 1/1 Test passed three times; the bogus configure prints VJ_BUILD_CHANNEL must be release, nightly or dev (got 'bogus') and rc=1. Then trash build-release build-nightly build-bogus.
- Step 5: Commit.
git add app/src/build_channel.h app/CMakeLists.txt app/src/app.cpp app/src/app.h app/tests/emu_thread_default_test.cpp app/cmake/VJEmuThreadTests.cmake
git commit -F - <<'EOF'
feat(app): build channel and the emulation-thread default rule (#967)
VJ_BUILD_CHANNEL (release | nightly | dev, default dev) sets VJ_EMU_THREAD_DEFAULT (1 for nightly). The default
applies only while ui.emu_thread is unwritten: an explicit Off stays Off in nightlies and an explicit On stays On in
releases. Nothing is built with the nightly channel yet.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task D2: Settings and About text, start-up log lines, emu_thread_default_smoke
Files:
- Modify:
app/src/ui.cpp:258-267(Interface tab hint) and:476(About),app/src/app.cpp:1938-1943(log lines, after the TV override),app/cmake/VJEmuThreadTests.cmake,app/tests/threaded_branch_allowlist.txt(app/src/app.cppcount +1) - No change to
app/src/settings_reset.cpp:ui.emu_threadis alreadyKeyClass::Interface(settings_reset.cpp:39) andsaveConfigerases the key while the choice is -1, so Reset restores the channel default;settings_reset_test.cppalready pins the class.
Interfaces:
-
Consumes:
kBuildChannel,kEmuThreadDefault,emuThreadPref,emuThreadWanted(),emuThreadThisRun(). -
Produces: two stderr lines on every start (headless included), exactly:
[emu] channel=<c>, emulation thread default <on|off>, this run <on|off>[emu] ui.emu_thread <unset|on|off> -> wanted <on|off>
and ctests
emu_thread_default_smoke_unset,_false,_true,_flag. -
Step 1: Write the failing smokes (
VJEmuThreadTests.cmake, after theemu_thread_default_unitblock). The PASS regexes are derived from the configured channel, so the same file pins every channel's behaviour:
# The default rule through the real app (Review Focus 4): the data dir's ui.emu_thread decides "wanted"; only an unset
# key follows the configured channel. Headless stays inline unless --emu-thread says otherwise, so "this run" is off.
set(_vj_chan_def off)
if(VJ_BUILD_CHANNEL STREQUAL "nightly")
set(_vj_chan_def on)
endif()
foreach(_p unset false true)
set(_d "${CMAKE_CURRENT_BINARY_DIR}/emu-default-${_p}")
file(MAKE_DIRECTORY "${_d}")
file(REMOVE "${_d}/config.ini")
if(NOT _p STREQUAL "unset")
file(WRITE "${_d}/config.ini" "ui.emu_thread=${_p}\n")
endif()
if(_p STREQUAL "unset")
set(_w "${_vj_chan_def}")
set(_s unset)
elseif(_p STREQUAL "true")
set(_w on)
set(_s on)
else()
set(_w off)
set(_s off)
endif()
add_test(NAME emu_thread_default_smoke_${_p}
COMMAND virtualjaguar --headless --frames 5 --data-dir "${_d}" "${VJ_ROMS}/yarc.j64")
set_tests_properties(emu_thread_default_smoke_${_p} PROPERTIES TIMEOUT 120
PASS_REGULAR_EXPRESSION "\\[emu\\] channel=${VJ_BUILD_CHANNEL}, emulation thread default ${_vj_chan_def}, this run off.*\\[emu\\] ui\\.emu_thread ${_s} -> wanted ${_w}"
FAIL_REGULAR_EXPRESSION "headless: FAIL|owner violation|ThreadSanitizer")
endforeach()
# --emu-thread wins over the stored choice for one run
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/emu-default-flag")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/emu-default-flag/config.ini" "ui.emu_thread=false\n") # its own dir: ctest -j runs the smokes in parallel
add_test(NAME emu_thread_default_smoke_flag
COMMAND virtualjaguar --headless --emu-thread on --frames 5 --data-dir "${CMAKE_CURRENT_BINARY_DIR}/emu-default-flag" "${VJ_ROMS}/yarc.j64")
set_tests_properties(emu_thread_default_smoke_flag PROPERTIES TIMEOUT 120 LABELS emu_thread
PASS_REGULAR_EXPRESSION "this run on.*\\[emu\\] ui\\.emu_thread off -> wanted off"
FAIL_REGULAR_EXPRESSION "headless: FAIL|owner violation|ThreadSanitizer")
Run cmake -S app -B build >/dev/null && ctest --test-dir build -R '^emu_thread_default_smoke' --output-on-failure. Expected: 4 tests, all Failed (the lines do not exist yet; the flag test also), with the Test output showing no [emu] channel= line.
- Step 2: Implement the log lines at app.cpp right after the
--tv-test-scriptoverride (~:1943), beforeif (a.tv):
std::fprintf(stderr, "[emu] channel=%s, emulation thread default %s, this run %s\n", kBuildChannel, kEmuThreadDefault ? "on" : "off", emuThreadThisRun_ ? "on" : "off");
std::fprintf(stderr, "[emu] ui.emu_thread %s -> wanted %s\n", emuThreadPref < 0 ? "unset" : emuThreadPref ? "on" : "off", emuThreadWanted() ? "on" : "off");
Settings (ui.cpp:260-267): the checkbox already shows emuThreadWanted(), which now includes the channel default; replace the hint's last two sentences ("While it is on, this version turns off the Tools ... Off by default.") with a note chosen by kEmuThreadDefault:
- on:
"This is a nightly build, where it is on unless you turn it off here. Release builds default to off." - off:
"Off by default here; nightly builds default to on."
passed as a %s argument of the same ui::hint(...) (it takes printf arguments, like the resume hint at ui.cpp:271); keep the if (app.emuThreadWanted() != app.emuThreadThisRun()) ImGui::TextDisabled("Restart the app to apply."); line. About (ui.cpp:476): ImGui::Text("Virtual Jaguar %s", VJ_APP_VERSION); becomes ImGui::Text("Virtual Jaguar %s (%s build)", VJ_APP_VERSION, kBuildChannel);.
- Step 3: Run, expect PASS.
cmake --build build -j4 --target virtualjaguar && ctest --test-dir build -R '^emu_thread_default_(unit|smoke)' --output-on-failure
bash app/tests/no_threaded_limits.sh "$PWD"
Expected: 5 passed. The second command reports app/src/app.cpp asks 'am I threaded?' <n+1> times, allowed <n>: the new this run ternary is a report, not a gate. Raise the app/src/app.cpp count by one in the allowlist and re-run: no_threaded_limits: OK.
Then the nightly configure end to end: env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build-nightly -DCMAKE_BUILD_TYPE=Release -DVJ_BUILD_CHANNEL=nightly >/dev/null && cmake --build build-nightly -j4 && ctest --test-dir build-nightly -R '^emu_thread_default|^smoke_' --output-on-failure -E shaders_gl. Expected: all pass; emu_thread_default_smoke_unset now requires default on ... wanted on, _false requires wanted off. Then trash build-nightly.
- Step 4: Commit.
git add app/src/ui.cpp app/src/app.cpp app/cmake/VJEmuThreadTests.cmake app/tests/threaded_branch_allowlist.txt
git commit -F - <<'EOF'
feat(app): show the build channel and the emulation-thread default (#967)
Settings > Interface says which way the default goes in this build, About names the channel, and the start-up log
reports channel, default, the stored choice and this run. The smokes derive their expected output from the
configured channel, so a nightly configure checks the On default and an explicit Off still winning.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
Task D3: workflows, flatpak, docs, the PR
Files:
- Modify:
.github/workflows/app-package.yml(metajob 76-128; the threecmake -S appinvocations ~:193, ~:370, ~:638; the three jobenv:blocks ~:173, ~:353, ~:610; a new step in theflatpakjob before "Build the bundle" ~:549),app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml:80-92,.github/workflows/app.yml(matrix ~:115),app/README.md:523-552,docs/agent/testing.md:309-340,docs/app-emulation-thread-design.md(§8 and §10 step 3),app/packaging/README.md .github/workflows/release.yml:596-598needs no change: it already passesrelease_tag(nightlyon develop pushes,prereleaseonrelease/**, thev*tag on tags) andapp-package.ymlderives the channel from that.
Interfaces:
-
Consumes:
release_tag(nightly|prerelease|v*|pr<N>|dev),needs.meta.outputs.label. -
Produces:
metaoutputchannel(nightly->nightly,v[0-9]*->release, anything else ->dev;prereleaseand PR builds aredev, which is Off like a release);-DVJ_BUILD_CHANNEL=on the macOS, Windows and Linux app configures; the flatpak manifest line- -DVJ_BUILD_CHANNEL=release, rewritten tonightlyby the flatpak job whenlabel == nightly; oneapp.ymlrow configured-DVJ_BUILD_CHANNEL=nightly. -
Step 1:
metaemits the channel. In thecase "$TAG": thev[0-9]*)branch gainschannel=release; the*)branch gainscase "$TAG" in nightly) channel=nightly ;; *) channel=dev ;; esac. Addecho "channel=$channel"to the{ ...; } >> "$GITHUB_OUTPUT"group,channelto the echo at the end, andchannel: ${{ steps.m.outputs.channel }}underoutputs:. -
Step 2: Pass it to the three configures the way
SHORT_VERSIONis passed (a job env var, never an inline${{ }}in the script). In each of the macOS (:173), Windows (:353) and Linux (~:610) jobenv:blocks addCHANNEL: ${{ needs.meta.outputs.channel }}; add-DVJ_BUILD_CHANNEL="$CHANNEL"on the line that carries-DVJ_PACKAGE_VERSION="$SHORT_VERSION"at ~:196, ~:371 and ~:638 (macOS builds both architectures in one loop: one edit covers both). -
Step 3: Flatpak. Manifest
config-opts(app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml, after- -DVJ_APP_BUILD_TESTS=OFF):
# app-package.yml's flatpak job rewrites this value to "nightly" for nightly builds (the manifest is committed, so
# it is the only per-channel knob); render.sh's Flathub variant keeps "release".
- -DVJ_BUILD_CHANNEL=release
and in the flatpak job, before "Build the bundle":
- name: Stamp the nightly channel into the manifest
if: needs.meta.outputs.label == 'nightly'
run: |
set -eu
m=app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml
sed -i 's/^\( - -DVJ_BUILD_CHANNEL=\)release$/\1nightly/' "$m"
grep -q -- '- -DVJ_BUILD_CHANNEL=nightly' "$m"
The grep fails the job when the sed matched nothing (a reindented manifest). The cache-key already hashes the manifest, evaluated after this step, so nightly and release get different flatpak-builder caches: expected. The ruby -ryaml lint at ~:159 runs on render.sh's output from the committed file and is unaffected. Check locally: bash app/packaging/manifests/render.sh --version 1.2.3 --sums <any SHA256SUMS.txt> --out "$(mktemp -d)" still ends without error, and ruby -ryaml -e 'YAML.safe_load_file("app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml")' && echo yaml-ok prints yaml-ok.
- Step 4: One CI configure with the nightly channel.
app.ymlmatrix, after theLinux x86_64row:
# The nightly channel's On default is built and tested here, so it is exercised without waiting for a
# nightly (emu_thread_default_smoke_* derive their expectations from this configure; #967).
- name: 'Linux x86_64 (nightly channel)'
os: ubuntu-latest
cmake_extra: '-DVJ_BUILD_CHANNEL=nightly'
Verify the workflows parse: ruby -ryaml -e 'ARGV.each { |f| YAML.safe_load_file(f, aliases: true); puts "ok #{f}" }' .github/workflows/app.yml .github/workflows/app-package.yml.
-
Step 5: Docs.
app/README.md"Emulation thread" (:523-552): replace "off by default (nightly builds will turn it on once nothing is switched off while it runs)" with "on by default in nightly builds and off in releases (the build channel; About shows it). A stored choice always wins: Reset in Settings > Interface goes back to the channel's default.devbuilds (pull-request artifacts, local builds) default to off;-DVJ_BUILD_CHANNEL=nightlybuilds the nightly default from source."; delete the paragraph "While it is on, this version switches off the Tools (debugger) windows and the GDB stub option; they move to the emulation thread in the next phase." (3a-3c should already have replaced it; confirm withno_threaded_limits), and say what a halt does threaded in one line each for the Tools and GDB if 3b's docs left it out.docs/agent/testing.md("App emulation thread tests", :309-340): add bullets forno_threaded_limits(what each of its six checks forbids, how to raise the allowlist honestly),emu_thread_default_unitandemu_thread_default_smoke_*(why four: a headless run is inline, so the stored-choice line is what pins the rule), the nightly-channelapp.ymlrow, andemu_thread_gate_check.sh(Studio only,VJ_WINDOWED_OK=1,--show-window tools,--perf-halt).docs/app-emulation-thread-design.md§8: after the 2026-10-20 amendment add "Shipped (phase 3d): as above; the default isnightlyonly,devis Off, andprereleasebuilds aredev. The flatpak job stamps the channel by sed because the manifest'sconfig-optsare committed." §10 step 3: append "3d merged after a green Studio gate (docs: PR #)".app/packaging/README.md: a short "Build channel" note: the three channels,-DVJ_BUILD_CHANNEL, where the packaging workflow sets it.
-
Step 6: Local checks.
bash app/tests/no_threaded_limits.sh "$PWD"
env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j4 && ctest --test-dir build -E shaders_gl -j4 --output-on-failure 2>&1 | tail -5
Expected: no_threaded_limits: OK; 100% tests passed. (Under heavy load the real-time netplay tests may flake: re-run them alone first.)
- Step 7: Commit, push, open the PR.
git add .github/workflows/app-package.yml .github/workflows/app.yml app/packaging/flatpak/io.github.libretro.VirtualJaguar.yml app/packaging/README.md app/README.md docs/agent/testing.md docs/app-emulation-thread-design.md
git commit -F - <<'EOF'
ci(app): nightly builds default the emulation thread on (#967)
app-package.yml derives the channel from release_tag (nightly, v* -> release, else dev) and passes
-DVJ_BUILD_CHANNEL to the macOS, Windows and Linux configures; the flatpak job rewrites the committed manifest's
channel to nightly for nightly builds. app.yml configures one Linux row with the nightly channel so the On default is
built and tested without waiting for a nightly. Docs updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
EOF
git push -u libretro feat/967-emu-thread-phase3d
Write the body to ~/Workspace/Provenance/vj-eval/remote/emu-p3d/pr-body.md, filling every cell from Task G1c (run values) and G1d (the table):
Refs #967 (phase 3d of 5: docs/app-emulation-thread-plan-phase3.md, design: docs/app-emulation-thread-design.md)
Nightly builds now default Settings > Interface > Emulation thread **On** for users who never chose; releases and
`dev` builds (PR artifacts, local builds) stay Off, and a stored choice always wins in both directions. Phase 3a-3c
(debug views, the halt and the GDB stub, Neuro) are merged: nothing is switched off while the thread runs, which is
what this PR's gate checks. Reverting this PR alone restores the Off default.
- `VJ_BUILD_CHANNEL` (release | nightly | dev, default dev) sets `VJ_EMU_THREAD_DEFAULT`; `emuThreadEffective(pref, default)`.
- Settings says which way the default goes; About shows the channel; the log prints `[emu] channel=..., emulation thread default ..., this run ...` and the stored choice.
- `app-package.yml` derives the channel from `release_tag`; the flatpak job rewrites the manifest's channel for nightlies; one `app.yml` row configures `-DVJ_BUILD_CHANNEL=nightly`.
- `no_threaded_limits` keeps the bar in CI: no threaded-only gate, no "off while the emulation thread is on" text, no old limit test, no unlisted `threaded()` branch, no growth of `tsan.supp`.
## Gate G1 (Mac Studio M4 Max, 60 Hz, <date>, head <sha>)
| check | result |
|---|---|
| full suite, Release (`-E shaders_gl`) | |
| `ctest -L emu_thread`, Release | |
| `ctest -L emu_thread`, ThreadSanitizer | |
| windowed gate, Release: tools open, F9 halt, Neuro | |
| windowed gate, Release: gdb halt held 5 s with tools open | |
| UI while halted, F9: median / p99 iter_ms | |
| UI while halted, gdb: median / p99 iter_ms | |
| Neuro presses answered | |
| windowed gate, ThreadSanitizer (no report, no owner violation) | |
| yarc repeats + skips / min: inline, Memory window open [runs] | |
| yarc repeats + skips / min: threaded, Memory window open [runs] (#1018: 12 + 31.5) | |
| emulation-thread frame cost, window open vs not (ms) | |
## Whole-branch architecture review (Fable, `git diff <pre-3a>..HEAD -- app/`)
| finding | severity | disposition |
|---|---|---|
| | | |
(or: no findings; checked: <its list>)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
gh pr create --repo libretro/virtualjaguar-libretro --base develop --head feat/967-emu-thread-phase3d \
--title "feat(app): nightly builds default the emulation thread on (#967)" \
--body-file ~/Workspace/Provenance/vj-eval/remote/emu-p3d/pr-body.md
Expected: the PR URL; on its checks, the new App Linux x86_64 (nightly channel) row, the tsan job and the package jobs (channel visible in the meta log line) go green. After the first push to develop the nightly job's log shows -DVJ_BUILD_CHANNEL=nightly in its configure and the flatpak step "Stamp the nightly channel into the manifest" runs. Remove the worktree when the PR merges: git worktree remove ~/Workspace/Provenance/vj-wt/emu-p3d.
Races and the tests that pin them
The named concurrency cases across all four sub-phases, with the mechanism that closes each and the test that fails if it reopens. (3b's own table above lists the halt cases in more detail.)
| Case | Mechanism | Test |
|---|---|---|
| A view buffer torn between the UI's read and the copier's swap | one mutex; the fill copies the request table out, calls the iface with no lock, installs under the lock only if the slot's rev is unchanged |
debug_views_unit testConcurrent, testFillHoldsNoLock (A2) |
| A buffer from the previous game drawn after a reload | DebugViews::epoch() bumped by onCoreIface() on the emulation thread; read() fails for an older epoch |
debug_views_unit testEpoch (A2); debug_views_threaded case 4 (A9) |
A UI-side setIface mutating bps_ / firing closeAllTools on the emulation thread |
split into onCoreIface() (emulation side, atomics + epoch) and syncCore() (UI side, once per frame) |
debug_session_unit testCoreSyncSplit (A3) |
| A paused window never refilled (no frame runs) | wakeViews() posts one fill to the debug lane; a parked loop wakes on the post |
debug_session_unit testViewsFill (A3); debug_views_threaded case 3 (A9); emu_thread_unit testDebugLane (A1) |
| The frame lane drained inside a halt, or the debug lane not drained outside one | two deques; loop() drains both at the boundary, the pump drains Lane::Debug only |
emu_commands_unit testLanes (A1); debug_e2e_threaded section 3 (B5) |
A call() queued behind a halting frame |
the give-up predicate; a cancelled task is skipped when drained | emu_thread_unit testCallGivesUp (B3); debug_e2e_threaded section 4 (B5) |
The pump outwaits GDBHalt()'s socket service or timeout |
the wait is waitVerdict's kPumpWait (4 ms); the pump returns NONE |
debug_session_unit testPumpNeverOutwaitsHook, testBoundedPumpReturnsNone (B1) |
| gdb resumed; the app's mirror stays "halted" and F9 resumes the next halt | frameEnded() after every retro_run; a verdict for a dead halt dropped by the serial check |
testFrameEndedClosesForeignResume (B1), testStaleVerdictDropped (B2) |
| Quit while halted (F9 or gdb) past the 3 s join | quitting_ / g_signalQuit -> abortHalt -> DETACH within one pump wait; a lane notify first |
testDetachEndsPumpWithinOneWait, testAbortHaltDetaches (B1); debug_e2e_threaded 7; gdb_threaded_halt step 5 (B5) |
Hardcore turned on from the UI while halted calls iface->detach off-thread |
the DETACH verdict when halted, a lane post otherwise | testHardcoreGoesThroughTheLane (B2); debug_e2e_threaded 6 (B5) |
| A Neuro program replaced mid-play replays or drops frames | lineage + firstFrame; the replica keeps played within a lineage |
neuro_unit testAgentPlayer 4 (C1), testAgentProgram 5 (C2) |
| A Neuro press of N frames lasting N UI frames instead | the replica advances once per core poll | neuro_core_e2e sessions 2-4 (C2); neuro_loopback_threaded (C4) |
Hardcore / a drop / release_all leaving a button held |
publishTo() after every tick(), unconditionally; enabled=false empties the replica at the next poll |
neuro_unit testAgentProgram 2-4 (C2); loopback rightNow / upNow (C2, C4) |
| Neuro watch bytes read off the emulation thread | a DebugViews request; watchBytes is a UI copy |
neuro_watch_inline / _threaded (C3, C4); TSan on the label |
| The nightly default overriding a stored choice | emuThreadEffective(pref, default): only pref < 0 follows the channel |
emu_thread_default_unit (D1); emu_thread_default_smoke_* (D2) |
| A gate coming back silently in a later change | no_threaded_limits (static, in CI) |
G1a |