Files
virtualjaguar-libretro/docs/app-emulation-thread-plan-phase3.md
85cd3f1642 feat(app): debugger halt and GDB stub with the emulation thread on (#967 phase 3b) (#1068)
* 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>
2026-10-23 02:36:04 -04:00

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 calling vj_debug_iface from the UI thread: periodic reads (memory, CLUT, registers, disassembly, the OP snapshot, the blit-log tail, the scanner's DRAM) become DebugViews requests 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 through EmuThread::debugCall. Inline, the same copier runs on the same thread, so the windows have one code path.
  • 3b, the halt and the GDB stub. DebugSession splits into a UI-thread mirror and DebugCore, the emulation-thread owner of the interface and the pump. The pump is a bounded wait (<= 4 ms) that serves the debug lane and returns VJ_DBG_RESUME_NONE to the core's own GDBHalt() loop, which keeps servicing the socket and the halt timeout. Verdicts are an atomic the UI writes. App::haltedFrame runs 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 an AgentPlayer replica one step per emulated frame and reports progress back. RAM watches are DebugViews requests.
  • 3d, the flag. VJ_BUILD_CHANNEL (release | nightly | dev) sets VJ_EMU_THREAD_DEFAULT; the default applies only while ui.emu_thread is 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 under src/ or libretro*.c changes. The halt loop in src/debug/gdbtarget.c and src/debug/vj_debug_iface.h are consumed as they are.
  • Only the emulation thread calls into the core: every retro_* call, every vj_debug_iface entry 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 (never master), each targeting develop with Refs #967 in the body (phases 4 and 5 remain, so no Closes). 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 is env -u DEVELOPER_DIR CC=/usr/bin/clang CXX=/usr/bin/clang++ cmake -S app -B build -DCMAKE_BUILD_TYPE=Release; the DEVELOPER_DIR=/Library/Developer/CommandLineTools prefix 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 alias studio-runner), after uptime shows a load under 2. Headless runs are fine anywhere, but check uptime first 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 on is 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 runs ctest -L emu_thread). app/tests/tsan.supp does 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 with Co-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, or command rm -f. Never a bare rm, cp or mv (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.

  1. 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 by emu_thread_unit testCallGivesUp "the call returned R() ... the cancelled task never ran" (Task B3) and debug_e2e_threaded section 4 "a save state requested just before a halt returns false within 300 ms" (Task B5).
  2. 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_unit testEpoch "a buffer filled before bumpEpoch() is not readable after it" (Task A2) and debug_views_threaded case 4 "after a reload the Memory window's first read is the new ROM's bytes" (Task A9).
  3. A Neuro press lasts a different number of emulated frames threaded vs inline. press_buttons frames=3 must 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 by neuro_unit testAgentPlayer "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) and neuro_loopback_threaded against the inline run's frame-numbered vector (Task C4).
  4. 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) and emu_thread_default_smoke_false / _true / _unset reading ui.emu_thread from a data dir, derived from the configured channel and run once under the nightly configure (Task D2).
  5. 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 by emu_commands_unit testLanes "drain(Lane::Debug) leaves the frame lane untouched and vice versa" (Task A1) and debug_e2e_threaded section 3 "a frame-lane option post made while halted applies only after Continue; optionValueFor reads the old value until then" (Task B5).
  6. Quitting while halted exceeds the 3 s join (the pump never sees the detach: quit read non-atomically, the lane not notified, or a gdb-held headless run with no way to quit). Pinned by debug_session_unit testDetachEndsPumpWithinOneWait and testAbortHaltDetaches (Task B1), debug_e2e_threaded section 7 "stop() returns true within 500 ms while halted" (Task B5) and gdb_threaded_halt step 5 "SIGTERM while gdb holds the halt: exit 0 within 3 s" (Task B5).
  7. The app stays "halted" after gdb continued (gdb's c, a dropped socket or the halt timeout leave GDBHalt() without a verdict of ours; the mirror never clears, F9 then resumes the next halt). Pinned by debug_session_unit testFrameEndedClosesForeignResume and testStaleVerdictDropped (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; A2 DebugViews; A3 DebugCore wrappers + session marshalling (inline unchanged, debug_session_unit green); 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, bounded pump() (mock tests); B2 App threaded halt wiring (waitVerdict, syncHalt, quitting_, shutdown detach); B3 call() give-up + call-site policy ("applies after Continue", detach-first, refusals); B4 gdb unlock, silent attach, hardcore lock; B5 debug_e2e_threaded, gdb_threaded_halt, limit tests rewritten; B6 docs, Studio checks, PR.
  • 3c (Neuro): C1 AgentProgram/AgentPlayer + InputSnapshot methods (unit); C2 Agent publishes/absorbs, agentControls removed, inputPoll; C3 watches through DebugViews; 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) and emu_thread_gate_check.sh with 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-lane drain, pending, closeAndDrain, waitUntil predicates at :61 and :70)
  • Modify: app/src/emu_thread.h (call :84-90 and its body :144-172 become one private submit; new debugCall, 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, closeAndDrain over both lanes; EmuThread::debugCall(F&&) -> decltype(fn()), EmuThread::debugPost(std::function<void()>). Not in this task: serveDebugLane and the giveUp argument (B1, B3).

  • Step 1: Write the failing tests. In app/tests/emu_commands_test.cpp, before main, 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: add enum class Lane : uint8_t { Frame, Debug }; (<cstdint>), std::deque<Fn> dbg_; beside q_. post(Fn&&, Lane = Lane::Frame) pushes into that lane's deque and sets wake_ as today. drain(const ErrorFn&, Lane = Lane::Frame) swaps out that lane's deque only. Both waitUntil predicates (the generic one and the MinGW pending lambda) become wake_ || !q_.empty() || !dbg_.empty(). closeAndDrain: each round swaps both deques out under the lock; if both were empty set closed_ and return; else run the frame batch then the debug batch, add both sizes to n. bool pending(Lane) const locks and tests that deque. Header comment: one sentence on the two lanes.
    • app/src/emu_thread.h: rename the body of call() into template <class F> auto submit(F&& fn, EmuCommands::Lane lane) -> decltype(fn()) (private; the code is unchanged except cmds_.post([task] { (*task)(); }, lane)); call(F&& fn) returns submit(std::forward<F>(fn), EmuCommands::Lane::Frame) and debugCall(F&& fn) returns submit(..., EmuCommands::Lane::Debug). callFor is untouched. Declare void debugPost(std::function<void()> fn); with the frozen comment (safe inside retro_run(); the wait hook contract is setWaitHook's). Document at debugCall: captures must be by value when the thread may be abandoned (the same rule as call), and there is no ordering between the two lanes beyond "frame lane first at each boundary".
    • app/src/emu_thread.cpp: debugPost is post()'s body with cmds_.post(std::move(fn), EmuCommands::Lane::Debug). In loop() replace line 154 with size_t ran = cmds_.drain(onError, EmuCommands::Lane::Frame); ran += cmds_.drain(onError, EmuCommands::Lane::Debug); if (ran > 0) client_->afterCommands();. closeAndDrain already covers both lanes.
    • app/cmake/VJEmuThreadTests.cmake: header lines 6 and 8 mention the lanes and debugCall (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 (add src/debug/debug_views.cpp after src/debug/debug_session.cpp at :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 .cpp only, so the headers do not cycle).
  • Produces: DisasmLine, ViewRequest, ViewBuffer, DebugViews exactly 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(). Clamps len to kMaxMemoryBytes and rows to kMaxDisasmRows. A request equal to the stored one (every field) changes nothing. A different one stores it, bumps the slot's private rev and clears front (serial = 0).
  • fill(iface, serial). (1) Return 0 if !iface. (2) Under the lock copy (id, req, rev) of every slot whose front.serial != serial || front.epoch != epoch(). (3) Per copied request, with no lock held, read epoch0 = epoch() first, then fill a local ViewBuffer (table below), setting serial, epoch = epoch0, frame = iface->frame_number(). (4) Under the lock, install it only if the slot still exists and its rev is unchanged; otherwise drop it. (5) Return the number installed.
  • read(). False when the id is unknown, front.serial == 0, or front.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, including ViewBuffer::req, DisasmLine::bytes, DisasmFn and setDisasm, plus a header comment of the lock discipline: one mutex, copied out before any iface call, installed under the lock with the slot's rev checked; set()/clear() may be called from a posted lambda on the emulation thread) and debug_views.cpp per the semantics and table above. The installed DisasmFn is copied out under the lock with the request table and called with no lock held. Add src/debug/debug_views.cpp to VJ_APP_SOURCES after src/debug/debug_session.cpp. debug_views.h includes <atomic> <cstdint> <map> <mutex> <string> <vector> and vj_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-158 pump, :162-218 breakpoints, :222-235 tick, :239-275 memory helpers, :288-294 regions)
  • Modify: app/CMakeLists.txt (add src/debug/debug_core.cpp after debug_session.cpp at :340; before include(VJDebugCpu) at :501 add set(VJ_DEBUG_SESSION_SRCS src/debug/debug_session.cpp src/debug/debug_core.cpp src/debug/debug_views.cpp)), app/cmake/VJDebugTests.cmake (the nine src/debug/debug_session.cpp entries at the targets vj_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_e2e become ${VJ_DEBUG_SESSION_SRCS}), app/cmake/VJDebugCpu.cmake (:37 vj_debug_cpu_test, :50 vj_debug_cpu_e2e: same replacement)
  • Test: app/tests/debug_session_test.cpp

Interfaces:

  • Consumes: DebugViews (A2).
  • Produces: DebugCore (iface wrappers, ifaceGen(), the transitional pumpHook / markDetached(); the halt is B1), and on DebugSession: core(), views(), coreCall, corePost, the public helpers callCore(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() and setThreadBlocked() remain, unchanged, until A9. The windows' "same core" token is views().epoch(); ifaceGen() is syncCore()'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_ (becomes std::atomic<uint64_t>; refreshSerial() loads it, requestRefresh() and the bumps fetch_add).
  • hasCore() = core_.loaded(); enabled() = core_.loaded() && loaded_ && !hardcore_ && !threadBlocked_; attached() = core_.attached().
  • coreCall(f) / corePost(f). Public helpers callCore(f) / postCore(f) run f now 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 (coreCall is 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.cpp add #include "debug_core.h" and, before main:
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 frozen DebugCore minus the halt section (halt(), haltSerial(), verdict, notifyVerdict, waitVerdict, runUiFrame, abortHalt, onHalt, onResume, kPumpWait are B1), plus the change-request members ifaceGen(), 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) returns pumpHook ? 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; else attached_ = false, store, gen_++. attach(): false without iface/attach pointer; true if already attached; else iface->attach(&host_) and set the flag. detach(): call iface->detach() only when attached; clear. opSnapshotGet: a VJ_DBG_OP_MAX_OBJECTS buffer on the heap, out.assign(buf, buf + n) for n > 0, else out.clear(); returns the iface's n. buildCryTable: 65536 calls of cry_to_rgb. No ImGui, no SDL.
    • debug_session.h/.cpp: as in the table above. Keep halted_, queued_, runUiFrame, abortHalt, onHalt, onResume, resumeQueued, setThreadBlocked, threadBlocked, iface() (now core_.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_SRCS and 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 touches vj_debug_iface and never calls read()/getRegsNow() from a path that runs every frame.
  • Every draw, after its views().set() calls, calls s.wakeViews() once. Inline that runs the copier at once (so the window sees its data on the next read(), 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 through s.postCore(fn). Never call s.coreCall directly: 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 (CryCodec 175-189, SearchJob 99-125), app/src/debug/memory_view.cpp (SearchJob::step 263-297, CryCodec 446-457), app/src/debug/memory_ui.h (test hook), app/src/debug/memory_ui.cpp (MemState 25-57, fetchWindow 86-104, syncCore 107-120, drawGrid 384-394 and 435-454, drawFooter 503-545, drawMemoryWindow 560-591, registerMemoryTools 611-616), app/src/debug/clut_ui.cpp (ClutState 23-42, refresh 49-63, drawClutWindow 88-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: compare m.epochSeen with s.views().epoch(); the reset body is unchanged plus winSerial = 0.
  • fetchWindow(s, r, visStart, visLen) replaces the synchronous read. Wanted span = the visible rows plus kMarginRows = 16 rows each side, clamped by clampToRegion and to DebugViews::kMaxMemoryBytes. Call s.views().set(m.winId, {Memory, spanStart, spanLen}) only when winId == kNoId, the visible rows leave [reqStart, reqStart+reqLen), or perRow changed. Then ViewBuffer b; if (s.views().read(m.winId, b) && b.req.addr == m.reqStart): when b.ok and (b.serial != m.winSerial or b.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.ok set winOk = false. A request not yet answered leaves the previous window drawn (cells outside it show ?).
  • drawFooter: replace the per-frame s.read(m.sel, b, 4) with a second Memory request {sel, 4} (selId, re-set when m.sel changes). Draw the cell line from the buffer when req.addr == m.sel && ok; "reading..." until answered; the existing "unreadable" text when answered with !ok. beginEdit and Follow pointer keep their one-shot s.read/s.readU (user actions).
  • drawMemoryWindow: when !m.open or !s.enabled() call releaseViews(s) before returning; call s.wakeViews() after the grid and footer have set their requests. registerMemoryTools' forced-close lambda captures &s and calls releaseViews(s).
  • memory_test::byteAt: true when winOk and addr lies 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 (after parseHex 116; TempBreak/CpuControl 118-170), app/src/debug/cpu_view.cpp (TempBreak 338-358, CpuControl::stepIn 400-418, stepOver 420-444), app/src/debug/cpu_ui.h, app/src/debug/cpu_ui.cpp (TabState 25-51, refreshRegs 74-84, setReg 104-113, drawDisasm 280-330, drawBanner 455-483, drawToolbar 485-512, drawWindow 545-617, registerCpuTools 631-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: ids regsId_, disId_; want calls views().set only for a changed field; update reads both buffers, adopts a Regs buffer when its serial differs from the adopted one, a Disasm buffer when req differs from the adopted request or its serial moved.
  • TempBreak::arm: after s.attach(), decide userArmed from s.breakpoints() (UI list) as today, then s.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: one callCore computing getRegs + stepOverTarget(s_.core().iface(), tab, r.pc) into locals, then the existing branches.
  • DisasmNav::back/prev = CoreJob<unsigned> over backStart / prevAddr x count.

cpu_ui.cpp:

  • TabState: drop regsSerial, rowsSerial, rowsTop, rowsN, rowsIface; rows becomes std::vector<DisasmLine> (L.valid becomes L.len != 0); add unsigned wheelUp = 0; bool navBusy = false;. One CpuFeed feed and one DisasmNav nav in CpuState.

  • refreshRegs is replaced by an adopt step in drawWindow: 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() after want. While !g.feed.answered() the window shows "Reading registers..."; the existing "The core did not return..." text appears only for an answered !ok buffer.

  • drawDisasm: rows come from feed.rows() when feed.rowsFor(target, t.top, n), else the previous rows stay. Wheel down: t.top = rows[whole].addr from the published rows (clamped to the last row). Wheel up: add to t.wheelUp; when !navBusy post nav.prev(s, target, t.top, t.wheelUp) and clear it; on nav.take set t.top, clear navBusy, and post again when wheelUp accumulated meanwhile. Follow-PC: when the PC row is not visible post nav.back(s, target, pc, min(4, n / 4)); on take set top. Initial !topValid: post nav.back and draw an empty list until it returns (inline: the same call). nextAddr/prevAddr/backStart are no longer called from the draw path.

  • setReg uses s.setReg(target, reg, v) (a callCore inside) and keeps s.requestRefresh(). drawToolbar: Break is enabled when !halted && s.haltAvailable(); Step/Continue/Step over/Run to cursor keep requiring halted; the tooltip of a disabled Break says "Halting is not available with the emulation thread yet" when !s.haltAvailable().

  • registerCpuTools: s.views().setDisasm(&disasmRows); the onHalt hook (R5) keeps t.prev = t.regs and adds s.requestRefresh() in place of t.regsSerial = 0; the forced-close hook also calls g.feed.release(s). drawWindow releases the feed when the window is closed or !s.enabled().

  • cpu_test::haveRegs/rowCount read g.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 (OpViewModel 16-90), app/src/debug/op_view.cpp (all of it, 190 lines), app/src/debug/op_render.h (after opColorToRgb 62), app/src/debug/op_render.cpp (opColorToRgb 56-69), app/src/debug/op_ui.cpp (drawPaintControls 259-317, drawObjectsTool 472-497, drawObjectsWindow 504-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;}) with renderGen_; CoreJob<WalkOut> walk_ (WalkOut {uint64_t gen; std::vector<OpObject> objs;}) with a held pendingSnap_ (decoded snapshot objects + halfline + count) used by the fallback; std::vector<uint32_t> cry_; geometry bytes geom_[0x20] + geomOk_.
  • arm(s): returns at once when !s.enabled() (the hardcore checks of testViewModelLifecycle count opArms); ensures snapId_ 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). No requestRefresh() here.
  • update(s, running) runs in this order: (1) return false when !s.enabled(); (2) ensure snapId_ (since = armGen_) and geomId_ ({Memory, 0xF00028, 0x20}) exist; (3) s.wakeViews(); (4) read both buffers and absorb when waiting_ && b.ok && b.req.since == armGen_ && b.opCount >= 0; (5) while waiting_, running and the buffer still reads opCount == -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 so waiting() is true right after the first update(), as testViewModelLifecycle asserts. A new epoch calls clear().
  • Fallback walk (followLinks && count == 0): walk_.submit of a job that reads OLP lo/hi with iface->peek(0xF00020/22, ...) and runs opWalkList(&peekReader, iface, lo | (hi << 16), VJ_DBG_OP_MAX_OBJECTS); the snapshot is committed (selection re-find, auto-select, imageDirty_) when walk_.take delivers, otherwise immediately.
  • rerender becomes startRender(s) + applyRender(): when the selected object is not a bitmap resolve at once; else render_.submit(s, [obj, opt, gen](const vj_debug_iface* i){ opRender(...) }), and applyRender() takes the result, drops it when gen != renderGen_, and applies the existing differs/imgSerial_ logic. paint() calls both in sequence (inline it completes before returning).
  • paint(s, x, y, value, status): one s.callCore computing opPixelPatch(s.core().iface(), ...), the RAM-region check against s.regions(), and s.poke(...) (nested, inline on the emulation thread); the status strings are unchanged.
  • readGeom(frameW, frameH): big-endian words of the {0xF00028, 0x20} buffer at offsets 0x00 (VMODE, whole), 0x10 (HDB1), 0x16 (VP), 0x1E (VDB), the last three & 0x7FF as today; default geometry when !geomOk_.
  • releaseViews(s): clears snapId_, geomId_. cryTable() builds cry_ through s.buildCryTable once per epoch (one callCore) when first asked for with a bitmap object selected.

op_ui.cpp:

  • drawPaintControls(const std::vector<uint32_t>* cry, ...) uses opColorToRgb(cry, img.mode, w); the caller passes vm.cryTable().

  • drawObjectsWindow: vm.update(session, ctx.emulationRunning); the geometry block calls vm.readGeom(ctx.frameW, ctx.frameH); when the window is closed or !session.enabled() call vm.releaseViews(session).

  • drawObjectsTool(App&): ctx.stepFrame from opStepFor({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 existing runFrame() + video.upload(host.frame()) lambda with app.debug.requestRefresh() first and app.debug.wakeViews() after (inline it runs the copier at once); None -> empty. The threaded branch calls no owner-only CoreHost method.

  • 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 (LogTracker 96-109), app/src/debug/blitter_log.cpp (after sync 194-216), app/src/debug/blitter_ui.h, app/src/debug/blitter_ui.cpp (State 21-34, setLog 38-55, drawLiveRegs 105-134, blitterToolDraw 197-249), app/src/debug/texture_pack.h (ReplaceOutcome/replaceTile 151-165), app/src/debug/texture_pack.cpp (replaceTile 513-534), app/src/debug/texture_ui.cpp (handlePending 282-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 (ScannerState 38-69, ramNow 84-99, forceClose 115-119, doScan 130-166, refreshRows 168-183, drawScanner 450-473), app/tests/debug_scan_ui_smoke.cpp, app/cmake/VJDebugTests.cmake
  • No change: app/src/debug/scanner_actions.cpp (scanPoke already goes through DebugSession::poke, a callCore after A3) and app/src/debug/scope_ui.cpp (it has no gate of its own: the gate was DebugSession::enabled() through toolAvailable).

Interfaces:

  • Consumes: ViewRequest::Kind::Dram, ViewBuffer::{bytes, ok, serial}, DebugSession::{views, wakeViews, snapshotDram, poke, toolAvailable, setHaltAvailable}.

  • Produces: the scanner keeps drawScanner(DebugSession&, CheatManager&, CoreHost&) and scanner_test::scan(DebugSession&, CoreHost&) unchanged (the CoreHost& is no longer read); new hook namespace 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) returns fb.data() after session.snapshotDram(fb). That is a DebugSession::read, i.e. one callCore -> EmuThread::debugCall: the 2 MB peek runs 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 raw memoryRegion pointer path is deleted (owner-only, counted as a violation threaded), so debug_scan_e2e's "narrow right after runFrame" holds. liveRam(session) reads g.dramId into the persistent ViewBuffer g.dram with views().read and returns g.dram.bytes.data() when ok && bytes.size() == DebugSession::kDramSize, else null.
  • doScan uses scanRam. refreshRows uses liveRam, and reads the buffer only when its existing gate passes (lastLive interval, serial change, rowsDirty): a read copies 2 MB under the views mutex.
  • ScannerState gets DebugViews::Id dramId. drawScanner sets {Dram} on the first draw while open and enabled, calls session.wakeViews() after it, and clears the id when closed, !enabled(), or in forceClose.
  • CoreHost& host stays in the signatures and is no longer used ([[maybe_unused]]); <core_host.h> and RETRO_MEMORY_SYSTEM_RAM leave 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, emuRunOneFrame tail :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 (remove iface(), setThreadBlocked, threadBlocked, threadBlocked_)
  • Create: app/tests/debug_views_threaded.cpp
  • Test/Modify: app/tests/debug_session_test.cpp (testThreadBlocked at :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_fallback with the new messages, test debug_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.

  1. "memory, dram and regs requests equal a debugCall peek at the same frame": load argv[1] with emu.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 id waitRead succeeds with ok, frame equals emu.debugCall([&]{ return dbg.core().frameNumber(); }), and bytes equals a debugCall peek of the same range (the Dram buffer is hashed against a peek of 2 MiB), regs.pc equals debugCall getRegs(M68K).pc.
  2. "a poke through the session shows in the next fill": dbg.pokeU(0x1F0000, 4, 0xCAFEF00D) == 4 (marshalled through debugCall; the poke bumps the serial), a Memory request at 0x1F0000 length 4, waitRead: bytes == {CA FE F0 0D}. No advance().
  3. "a new request is readable while paused, with no advance()": record emu.framesRun(), set a ViewRequest at 0x2000 on the test thread, waitRead: CHECK(ok, "readable within 500 ms with no frame run") and emu.framesRun() equals the recorded value. Then change the request to 0x3000 (a scroll while paused) and waitRead again: the new range, same serial.
  4. "after a reload the Memory window's first read is the new ROM's bytes": Memory request 0x800000 length 65536 (the cartridge). emu.advance(2), copy the buffer as romA. emu.call(loadContent(argv[2])) (onCoreIface fires on the emulation thread twice: null, new), then dbg.syncCore(). Immediately CHECK(!views().read(id, b) || b.bytes != romA, "never the previous game's bytes"); then waitRead (the epoch bump makes the old buffer pending at the same serial); CHECK(b.bytes == (debugCall peek of 0x800000, 65536)) and CHECK(b.bytes != romA) (if this fails the two ROMs share 64 KiB: pick different ROMs, do not weaken it).
  5. "inline and threaded give the same Dram view": a helper uint32_t dramHash(bool threaded) that builds a fresh Rig (its own EmuThread and DebugSession; coreCall captures that rig's emu, and a stopped EmuThread is not restartable), wires it, loads argv[1], sets a Dram request, emu.advance(60), requestRefresh(), waitRead, FNV of the buffer, then emu.call(unloadContent) and emu.stop() as session() in emu_thread_e2e.cpp does. Inline is Mode::Inline with corePost unset (the helper's wakeViews() then fills directly and advance runs inlineStep). CHECK(dramHash(false) == dramHash(true), "inline and threaded give the same Dram view"). Cases 1-4, 6 and 7 share one threaded Rig; the owner-violation count is checked once at the end of each rig.
  6. "no halt is possible while it is gated": dbg.setHaltAvailable(false); dbg.breakNow(); emu.advance(5); CHECK(!dbg.attached() && !dbg.halted()).
  7. "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.

    1. app.h: declare void fillDebugViews(); (comment: emulation thread, inline the UI thread; debug.fillViews()). app.cpp: define it. Call it (a) at the end of emuRunOneFrame() after host.publishSnapshot() (:677); (b) in stepEmulation() once after the for (frames) loop, not inside it, so fast-forward does not fill four times per UI frame. haltedFrame() needs no separate call: it already runs syncDebug(), and wakeViews() below fills inline there (record this in the comment).

    2. wireHost() (:498): replace host.onDebugIface with [this](const vj_debug_iface* i) { debug.onCoreIface(i); if (!host.threaded()) debug.syncCore(); }, and add debug.coreCall = [this](std::function<void()> f) { emu.debugCall(std::move(f)); }; and debug.corePost = [this](std::function<void()> f) { emu.debugPost(std::move(f)); }; (before EmuThread::start the thread is inline, so both run at once; headless and inline runs are unchanged).

    3. syncDebug() (:522): first line debug.syncCore();, last line debug.wakeViews(); (after tick).

    4. Limits: limitFeaturesForEmuThread() becomes debug.setHaltAvailable(false); plus the existing option lock for virtualjaguar_gdb_stub; delete debug.setThreadBlocked(true) and host.announceDebug = false (the core must hand its interface over; it cannot halt because attach() 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); (no announceDebug line), log [emu] the debugger halt and the GDB stub are back on (the emulation thread did not start). Update the comment above limitFeaturesForEmuThread (:528-532) and app.h:335.

    5. run() :2021: else if (openDebugWindow(debug, a.showWindow)) {} (drop !emuThreadThisRun_ &&).

    6. Break/Step with the halt gated: debug_ui.cpp:61 MenuItem("Break", "F9", false, on && d.haltAvailable()) with a hover tooltip d.haltDisabledReason() when greyed for that reason; app.cpp F9 (:1655-1656) and desktop.cpp debugBreakContinue() (: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 inside breakNow()/attach(); this is only the explanation.

    7. ui.cpp:262-266 hint: 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."

    8. Remove DebugSession::iface(), setThreadBlocked(), threadBlocked() and threadBlocked_ (and the threadBlocked_ term in enabled() and the first line of disabledReason()). Switch the tests that called dbg.iface() to dbg.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 rows src/debug/debug_core.* (the emulation-thread half of the debug session: the interface wrappers and, in the next phase, the halt) and src/debug/debug_views.* (the per-window requests the emulation thread fills).
    • docs/app-debug-tools.md: Pieces table gains app/src/debug/debug_core.{h,cpp} and debug_views.{h,cpp}; "Halt model" intro: "All calls are made on the thread that runs retro_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 a DebugViews request 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 through EmuThread::debugCall and 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: add debug_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 in emu_commands_unit / emu_thread_unit, debug_views_threaded (the REAL core: views equal a debugCall peek, a paused request fills with no frame, the first read after a reload is the new ROM, inline == threaded), and the reworded emu_thread_limits / emu_thread_start_fallback.
    • docs/app-emulation-thread-design.md: a dated (2026-10-20) amendment under section 5 recording DebugViews' contract (requests keyed by id, filled by fill(iface, serial) after each frame, a changed request or a content switch invalidates its buffer) and Lane::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 uptime on 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 plus frameEnded()), 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/detachAndResume write core().verdict; detach() while halted writes DETACH; the old pump/queued_/halted_ fields go)
  • Modify: app/tests/debug_e2e.cpp (dbg.runUiFrame / dbg.onHalt / dbg.onResume / dbg.abortHalt become dbg.core().runUiFrame / core().onHalt / core().onResume / core().abortHalt; every runUiFrame body starts with dbg.syncHalt() once B2 lands it, until then halted() reads the core directly; the counts at :137-141 hold), app/src/debug/cpu_ui.cpp (registerCpuTools' onHalt hook moves to s.core().onHalt for this commit: halts are impossible threaded until B4, so the emulation-thread callback touching g.t is safe here; B2 moves it to onHaltUi)
  • Test: app/tests/debug_session_test.cpp (new sections against DebugCore + the mock), app/tests/emu_thread_test.cpp (serveDebugLane), app/cmake/VJDebugTests.cmake (vj_debug_session_test links ${VJ_DEBUG_SESSION_SRCS}, gets LABELS 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.h block): 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's size covers it (as debug_session.cpp:129-131) else VJ_DBG_STOP_BREAKPOINT, serial++, fire onHalt(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. Otherwise halted = false, target = -1, reason = NONE (pc kept for the bar's last value); DETACH -> attached_ = false; CONTINUE/DETACH -> serial++, fire onResume; return v. The mutex guards HaltState; serial is also mirrored in an std::atomic<uint64_t> for haltSerial().

  • serveDebugLane(maxWait): inline (!threaded()) -> cmds_.drain(onError, Lane::Debug) and return. Threaded: n = drain(Debug); if n == 0: cmds_.waitUntil(now + maxWait) then n = drain(Debug); return n. Must be called on the emulation thread (assert onEmuThread() 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, before main, these sections (the file's CHECK macro; 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 plus void frameEnded();; private mutable std::mutex haltMu_; HaltState halt_; std::atomic<uint64_t> serial_{0};. DebugCore() keeps A3's host_ fill and sets host_.halt_pump = &haltPumpThunk. setIface() drops the halt (state cleared under the mutex, no callbacks, no serial bump) as well as attached_. pump() per the contract above; frameEnded() per request 1 (fires onResume outside the mutex; copies of the callback vectors are walked, as debug_session.cpp:133 does).
    • emu_thread.h/.cpp: serveDebugLane per the Interfaces bullet.
    • debug_session.h/.cpp: remove pump, 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 plus userHaltPending_ = true, detachAndResume() with DETACH. detach() = halted() ? detachAndResume() : corePost([this]{ core().detach(); }) then attachedMirror_ = false (the mirror of attached() is refreshed by syncHalt() from core().attached(); B2 adds syncHalt, so for B1 halted()/attached() read core().halt().halted / core().attached() directly and B2 swaps in the mirror). serial_ becomes std::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_, setHardcore through the lane), app/src/app.h (haltWait, quitting_, quitRequested, remove haltedNow's comment about inFrame), app/src/app.cpp (wireHost 497-515: hooks move to debug.core(); syncDebug 522-526: + debug.syncHalt(); startEmuThread 592-611: debug.core().waitVerdict = [this]{ haltWait(); }; liftFeatureLimits 547-554: clears it; shutdown 412-425 and stopEmuThread 633-641: detach first; emuRunOneFrame 654 and stepEmulation 1833: debug.core().frameEnded() after host.runFrame(); haltedFrame 915-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" when d.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: the t.prev = t.regs; s.requestRefresh() hook moves from s.core().onHalt (B1) to s.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 DebugSession B2 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: onHaltUi if now halted, onResumeUi if 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, before main:

// 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() sets userHaltPending_ = true before the haltNext post. setIface(nullptr) and setContentLoaded(false) reset the mirror (no edge callbacks).
    • app.cpp wireHost(): 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); }) (paused is read on the emulation thread here: use emuPaused_-style atomic if one exists after Phase 2's syncEmuFlags, else emu.paused()); the toast and pacer_.reset(); lastTick_ = now(); move to debug.onHaltUi / debug.onResumeUi (UI thread), the toast text unchanged, plus if (debug.haltByGdb()) the toast says "Halted by gdb: %s at $%06X - F9 continues". One unconditional stderr line in the emulation-side onHalt: [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 success debug.core().waitVerdict = [this]{ haltWait(); };; liftFeatureLimits() sets it to nullptr. haltWait() per Interfaces.
    • shutdown() (412-425): before the callFor unload: quitting_.store(true); if (debug.halted()) debug.detachAndResume(); emu.debugPost([]{}); (the post wakes a pump parked in waitUntil). 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() after host.runFrame() (654): debug.core().frameEnded();; stepEmulation() after 1833 likewise.
    • haltedFrame(): the guard emu.onEmuThread() becomes threaded(); it detaches via debug.detachAndResume() as today (with syncHalt() 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: openContent 1064-1068, bootBios 1195-1199, closeContent 1238-1243, reset 1264-1270, handleDrop 1480-1492, saveState 1514-1534, loadState 1556-1581, autoSave, runHeadlessFrames 898-907, setBiosChoice 1212, 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() returns R(); 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 two std::shared_ptr<std::atomic<bool>>; the waiter's loop checks giveUp && !started->load() && giveUp() after each wait_for slice and, when true, does cancelled->store(true) and re-checks started (a task that began between the two reads is waited for: done.get()); otherwise returns R().

  • App: std::function<bool()> haltGiveUp() const; = [this]{ return debug.core().halt().halted; } (any-thread read); std::function<bool()> detachForCall(); = threaded and debug.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), else haltGiveUp().

  • 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 (through openContent), disc eject/index/insert (ui.cpp:585,591 and showDiscDialog's insert), quit (B2). Inline keeps today's refusal for all of them (a call runs at once inside retro_run, core_host.cpp refuses with inFrame_): detachForCall() inline returns {} after the existing haltedNow() toast path, i.e. the if (haltedNow()) { toast; return } blocks stay for !threaded(). Every remaining UI-initiated emu.call that is not in those lists (setBiosChoice, ach coreCalls, audio.clear in headless) passes haltGiveUp() and treats R() as "not now".

  • "Applies after Continue": drawDebugOverlay adds ImGui::TextDisabled("Settings and cheats apply after Continue") under the buttons; ui.cpp's Core options tab (drawCoreOptions, line 361's callee) starts with the same TextDisabled line when app.debug.halted().

  • Step 1: Write the failing tests. In app/tests/emu_thread_test.cpp, before main:

// 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 new call per Interfaces (the header comment from the Interfaces section above the declaration). callFor unchanged.
    • app.cpp call sites per the policy. saveState: after the isLoaded check, if (debug.halted()) { if (!quiet) toast("The debugger has the game halted - resume it first (F9)", 2.5, 2); return false; } and emu.call([&]{...}, haltGiveUp()); on R() (false) with err.empty(): the same toast. loadState the same. openContent: replace 1065-1068 with if (haltedNow() && !threaded()) { toast(...); return false; } auto giveUp = detachForCall(); and emu.call([&]{...}, giveUp); on false with err.empty(): err = "the debugger halted the game again". closeContent, reset, bootBios (refuse: keep 1196-1199 as is, text updated), handleDrop (no change: openContent decides; its flushSaveRam call gets haltGiveUp()), runHeadlessFrames and 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() with ImGui::SetItemTooltip("Resume the debugger first (F9)") when disabled by the halt; ui.cpp:585,591 calls pass app.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 (limitFeaturesForEmuThread 533-544 and liftFeatureLimits 547-554 deleted with their call sites at 1953 and 605; wireHost's optionLock 482-484 gains the hardcore rule; syncDebug gains syncGdbHost(); startEmuThread logs [emu] emulation thread on as 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_threaded 199-204 -> gdb_enabled_threaded; new gdb_hardcore_locked, gdb_hardcore_locked_threaded; emu_thread_limits 194-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, from syncDebug(): 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) / setIface paths reset gdbHostTried_ through the !isLoaded branch. 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()'s host.optionLock becomes [this](const std::string& k, std::string& v) { if (k == "virtualjaguar_gdb_stub" && gdbLockedByHardcore_.load()) { v = "disabled"; return true; } return lockedCoreOptionFor(k, v, {{...}}); }; syncDebug() writes gdbLockedByHardcore_ from ach.hardcoreActive() and on a change calls host.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 prints virtualjaguar_gdb_stub=disabled (locked) (as pro_controller_option_on relies 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/liftFeatureLimits and emu_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.announceDebug stays true always; debug.core().waitVerdict is cleared in the failure branch of startEmuThread). syncGdbHost(), the lock, refreshLocks() on the edge. The transitional halt gate goes with the limits: delete DebugSession::setHaltAvailable / haltAvailable / haltDisabledReason and haltAvailable_ (toolAvailable becomes enabled() && t.toggle && (!t.needsHalt || halted()), attach() / breakNow() / addBreakpoint() lose the term), the A5 tooltip "Halting is not available with the emulation thread yet" in cpu_ui.cpp, the F9 / debugBreakContinue else toast(debug.haltDisabledReason(), ...) branches (app.cpp, desktop.cpp) and the Break menu item's tooltip (debug_ui.cpp); in the tests delete testHaltAvailable (debug_session_test.cpp), the s.setHaltAvailable(false) lines in debug_cpu_ui_smoke.cpp and debug_scope_gate_test.cpp (their offCore() == 0 checks stay), and case 6 of debug_views_threaded.cpp. /usr/bin/grep -rn "haltAvailable\|haltDisabledReason\|setHaltAvailable" app/ docs/app-debug-tools.md app/README.md must 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() reads g_signalQuit; abortHalt; the headless loop 2547-2554 breaks on quitRequested() and prints headless: 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/DebugCore as App wires them (the rig below mirrors wireHost/startEmuThread/haltWait exactly 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 in main.cpp, installed only for --headless runs (std::signal(SIGINT, h); std::signal(SIGTERM, h); where h stores true; <csignal>; windowed runs keep SDL's own handlers, which turn the signal into SDL_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)):

  1. 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; }));
  2. 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());
  3. 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); then resetOptionToDefault.
  4. Review Focus 1: a save state requested just before a halt returns false within 300 ms (deterministic: a halt_next armed 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); }); where s4 is the serial of the current halt taken before resume(); 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); then resume() and CHECK(r.emu.call([&]{ return r.host.saveStateToFile(stateFile, err); }, [&]{ return r.dbg.core().halt().halted; })); (no halt now: the save runs).
  5. 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).
  6. 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);
  7. 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 with quitting only (no verdict): a fresh Rig + load + halt, r.quitting = true; r.emu.debugPost([]{}); CHECK(r.emu.stop() && since(t0) < 0.5) (abortHalt alone frees the join).
  8. A halt during a debugCall issued from the UI (named deadlock case): breakNow() posts haltNext to the debug lane; the next UI action is r.dbg.read() (a debugCall): 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>):

  1. 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).
  2. 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] with stderr=PIPE (a reader thread collects lines), connect within 5 s, +, qSupported, expect the boot-wait stop T05thread:1; within 10 s.
  3. Assert the app's stderr shows [debug] gdb host attached (silent) and [debug] halt: 68000 at $ within 2 s; m0,10 answers 32 hex chars while halted; sleep 0.5 and the app is still alive (proc.poll() is None).
  4. c -> the app finishes its 300 frames: proc.wait(timeout=30) returns 0 and stdout ends with headless: OK (1 load(s), emulation thread).
  5. 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 has headless: quit requested, exiting cleanly and no did not stop within 3 s. (Windows: skip step 5 with a printed note; SIGTERM there is a hard kill.)
  6. Print PASS: gdb halt on the emulation thread, memory while halted, continue, quit mid-halt and 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.cpp handlers before App::run; App::quitRequested(); abortHalt; the headless threaded loop (2547-2554) becomes for (...; i < a.frames && !quitRequested();) with the message and rc = 0 after the loop when quitRequested(); the inline headless loop (2558-) the same check per frame. The interactive loop's while (!quit) becomes while (!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_unit label, 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() calls halt_pump every 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; uptime under 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 after takenSince, :48; members after :57)
  • Modify: app/src/neuro/neuro_actions.h (InputQueue :56-72; new AgentPlayer, pollAgent after it; include input_snapshot.h)
  • Modify: app/src/neuro/neuro_actions.cpp (InputQueue :158-190; new definitions after :190)
  • Modify: app/cmake/VJNeuroTests.cmake (the vj_neuro_test target :9-20)
  • Test: app/tests/neuro_test.cpp (new testAgentPlayer, testQueueProgram, registered in main after testQueue, :883), app/tests/input_snapshot_test.cpp (new testAgentChannel)

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. InputSnapshot holds a CoreInput, whose carryMotion/clearMotion live in remap.cpp. In VJNeuroTests.cmake change the target to add_executable(vj_neuro_test tests/neuro_test.cpp src/remap.cpp src/config.cpp src/paths.cpp ${VJ_NEURO_SOURCES}) and its include line to target_include_directories(vj_neuro_test PRIVATE src src/neuro $<TARGET_PROPERTY:vj_core,INTERFACE_INCLUDE_DIRECTORIES>) (the same three sources and generator expression as vj_input_snapshot_test, VJEmuThreadTests.cmake:61-62).

  • Step 2: write the failing tests.

    input_snapshot_test.cpp, testAgentChannel() (add to main; plain CHECK(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 where steps.size() == gen % 7 and hold.bits == gen; the emulation thread loops takeAgent and reportAgent, asserting each taken program satisfies both relations and gen never 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 a CHECK):

    1. "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 has progress().played == 3 && !done; after 9, played == 9 && done.
    2. "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.
    3. "gen reload replaces (new lineage)": after (1) load(prog(9,8,0,{{keys({C_A}),2}})): next frame() == keys({C_A}), progress().played == 1 && lineage == 8 && gen == 9.
    4. "append and hold change mid-play equal one uninterrupted queue": reference InputQueue ref; ref.enqueue({{B,6},{UP,2},{ControlSet(),2},{B,1}}), ref.setHold not used, 11 ref.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}))) -> next frame() == keys({C_C}) and played unchanged.
    5. "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) == "###" and played == 4.
    6. "firstFrame ahead of played is clamped": fresh player, load(prog(1,1,5,{{B,3}})): pat(4,C_B) == "###.", played == 8.
    7. 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; setHold again with the same set -> still 2; program(7,true) has gen==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.

  • 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_test
    

    Expected: errors "unknown type name 'AgentProgram'" / "no member named 'publishAgent'".

  • Step 4: implement.

    • input_snapshot.h: the three structs exactly as in change request 1 (AgentStep first); the four methods with the Interfaces-section signatures. A separate std::mutex agentMu_ (the per-event mu_ is not touched) and std::atomic<uint64_t> agentGen_{0}; takeAgent returns false without locking when agentGen_ == takenAgentGen_ (emulation-thread-only member), else locks, copies, sets takenAgentGen_ = out.gen. publishAgent stores under the lock, then agentGen_.store(p.gen). reportAgent/agentProgress copy under agentMu_.
    • neuro_actions.h/.cpp: InputQueue gets members uint64_t version_ = 0, lineage_ = 0, firstFrame_ = 0; and the methods of change request 2 (enqueue success, setHold-when-changed and clear bump version_; clear also does lineage_++, firstFrame_ = 0; program(gen, enabled) converts each Step to AgentStep). Everything existing (frame, peek, busy, queuedFrames) is unchanged: the player reuses them.
    • AgentPlayer (members InputQueue 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-failure
    

    Expected: 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-37 GameState stays for now; :72 frame() removed; :79 pressedNow; 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, new neuroHeadlessReport)
  • Modify: app/src/input.h (:53-54 removed, addAgentControls added), 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 to inputSnap; neuroHeadlessReport declaration :540)
  • Create: app/tests/neuro_core_e2e.cpp
  • Modify: app/cmake/VJNeuroTests.cmake
  • Test: app/tests/neuro_test.cpp (port testAgentGating :408-490 and loopback :765-872; new testAgentProgram; new app mode)

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's pressed; 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; }
    };
    

    testAgentGating is ported by replacing each agent.frame() with rep.poll(agent) (a Replica 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 after agent.tick(0.1, g)), nothing is queued behind the menu, the mouse reason, no actions/force while blocked, and hardcore: after agent.configure(s) with s.hardcore = true (note: tick() is not called, as in neuroTick, which calls configure then tick which 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
    
    1. "busy covers progress from an older gen": agent.link().onMessage(press) then agent.publishTo(rep.snap); before any poll agent.absorb(rep.snap.agentProgress()) (default progress: gen 0, lineage 0) leaves queue busy, observed as: agent.tick(1.0, g) with forceInterval = 0 sends no actions/force. After three rep.poll(agent) calls the next agent.tick(2.0, g) does send one, and agent.framesPlayed() == 3.
    2. "release_all drops queue and hold at the next poll": onMessage(hold), onMessage(press), rep.poll twice (right+b pressed), onMessage(release_all), rep.poll(agent) is empty and !agent.pressedNow().any(), and the following three polls too.
    3. "hardcore clears the replica within one poll": hold right, rep.poll presses right; s.hardcore = true; agent.configure(s); without agent.tick; rep.poll(agent) is empty and rep.snap.takeAgent was seen with enabled == false (record rep.player state: rep.player.progress().pressed empty); agent.configure back to hardcore = false and a fresh press plays again.
    4. "turning Neuro off clears the replica" (the neuroShutdown path): hold right, poll, agent.configure(AgentSettings()), rep.poll(agent) empty.
    5. "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 bbbbbb then up on 2: B B B B B B U U exactly (the replica kept played).

    loopback() port: the app thread's agent.frame() (:780) becomes rep.poll(agent) with Replica rep; local to the app thread (single thread, inline-shaped), everything else (pressedB == 9, rightNow, upNow flags, hardcore) unchanged. Additionally record the frame-numbered vector for C4: once the first C_B poll is seen, append pressed.test(C_B) per poll to a std::vector<uint8_t> rec until it holds 15 entries; after the force2 wait assert rec == {1,1,1,0,0,0,1,1,1,0,0,0,1,1,1} (fire x3 = three 3-frame presses with a 3-frame gap, the Tempest macro). Extract the loop body into static int loopbackRun(bool threadedPoll); this task registers only threadedPoll == false.

    New app mode in main (POSIX; return 77 on Windows): vj_neuro_test app <virtualjaguar> <rom> <on|off> opens a FakeServer, spawns the app with popen (<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 be success, then drains messages until the child exits (pclose, status 0). The output must contain [neuro] headless: 30 frames of input played, not contain owner violation or ThreadSanitizer, and for on contain emulation thread).

    neuro_core_e2e.cpp (usage vj_neuro_core_e2e <rom.j64>; copy Rig/session from emu_thread_e2e.cpp:84-137, dropping FrameExchange and the pixel hash; the record per frame is fnv(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.inputState as there. Three sessions of 120 frames, lock-step (emu.advance(1) per frame, program published before frame 60):

    1. idle: no program (base).
    2. "9 polls, inline": Mode::Inline, program prog(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 exactly i in {60,61,62,66,67,68,72,73,74}.
    3. "same threaded": Mode::Threaded; rec == rec(2).
    4. "free-running at 600 Hz": threaded, not paused, program published after emu.framesRun() >= 30; wait until emu.framesRun() >= 200; the number of frames with rec[i] != base[i] (compare against the idle matrix value, not indices) is exactly 9.
    5. disabled: program with enabled=false: rec == base.
  • 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_warnings is defined in VJEmuThreadTests.cmake; include order in app/CMakeLists.txt puts it first. If VJNeuroTests is 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-failure
    

    Expected: compile errors for publishTo/absorb/framesPlayed.

  • Step 3: implement Agent. In neuro_agent.h: remove frame(); pressedNow() returns pressed_ (a ControlSet member); add void publishTo(InputSnapshot&), void absorb(const AgentProgress&), uint64_t framesPlayed() const; private uint64_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, absorb are UI-thread; the replica is AgentPlayer, 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 by tick, 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. stateText keeps queue_.hold(). setInputBlock, configure, pump, gameChanged already call queue_.clear(); no change.

  • Step 4: implement the app side.

    • input.h: delete agentControls and its comment (:53-54); add void addAgentControls(ControlSet c);. input.cpp: delete :612; define void InputManager::addAgentControls(ControlSet c) { if (c.any()) core_.addControls(0, c); }.
    • app.h: under #ifdef VJ_HAVE_NEURO a member neuro::AgentPlayer agentPlayer_; // emulation thread only (the input poll); declare void neuroHeadlessReport(App& app);.
    • app.cpp host.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_));
      #endif
      
    • neuro_app.cpp: neuroTick becomes: 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 inside tick's pump sees a trimmed queue; publish after, unconditionally, so tick's early return on !allowed() does not hide a clear). neuroShutdown: after configure(AgentSettings()) call app.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.cpp headless: in the inline loop call neuroHeadlessReport(*this) once after the TIMING line when !neuroCliUrl.empty() (under VJ_HAVE_NEURO). The threaded headless branch is C4's.
    • Delete Agent::frame uses nowhere else (grep -rn "neuro.frame\|agentControls" app/src app/tests must be empty).
  • Step 5: run, expect PASS.

    cmake --build build -j4 && ctest --test-dir build -R "neuro|input_snapshot|emu_thread_e2e" --output-on-failure
    

    Expected: neuro_unit, neuro_loopback, neuro_core_e2e, neuro_app_inline, neuro_off_threaded (still gated off, unchanged), input_snapshot_unit, emu_thread_e2e PASS.

  • 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 keeps core_ 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; new watchRange, kMaxWatchSpan), app/src/neuro/neuro_agent.cpp (stateText :165-179, watchRange)
  • Modify: app/src/neuro/neuro_actions.h/.cpp (watchValue overload :90, :295-312)
  • Modify: app/src/neuro/neuro_app.cpp (gameState :95-96, the retro_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 neuroWatchAt beside :185-188), app/src/app.cpp (headless hooks)
  • Modify: app/cmake/VJNeuroTests.cmake
  • Test: app/tests/neuro_test.cpp (testWatches), CTest neuro_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 (replacing ram, ramSize), bool Agent::watchRange(uint32_t& base, unsigned& len) const (false without watches), constexpr unsigned kMaxWatchSpan = 65536, the 4-argument watchValue.

  • Step 1: write the failing tests. testWatches() in neuro_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 Agent test with a profile loaded from a temp directory (std::filesystem::temp_directory_path() / "vj-neuro-watch", a file t.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) with g.title = "Tempest 2000" so followGame picks it): watchRange(base,len) is true with base == 0x100 && len == 2; with a third watch at 0x1F0000 size 1 in a second file u.json (titles ["Wide"]) watchRange gives base == 0x100 && len == 0x1EFF01, which is > kMaxWatchSpan; a profile without watches returns false. stateText with g.watchBase = 0x100, g.watchBytes = {0x12,0x34} contains - score: 1234 and - lives: 18; with empty watchBytes it 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::Dram path (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_test fails: no 4-argument watchValue, no watchRange).

  • Step 3: implement.

    • watchValue 4-arg: index address - base; "?" when bytes is null, address < base or address - base + size > n; the 3-arg form calls it with base = 0.
    • Agent::watchRange: over the current profile's watches, base = min(address), len = max(address + size) - base; false with no profile or no watches. stateText calls watchValue(w, g.watchBytes.data(), g.watchBytes.size(), g.watchBase); GameState drops ram/ramSize and gains the two members. Remove the externs and the two retro_get_memory_* lines in neuro_app.cpp.
    • neuro_app.cpp, a static void neuroWatches(App& app) called at the end of neuroTick (after publishTo):
      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();
      }
      
      (t is neuroTick's clock.) neuroTick hands the cached bytes to tick without a copy: after GameState g = gameState(app); do g.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 calls iface->peek itself.
    • Headless needs the copier too, because the headless loops bypass emuRunOneFrame()/stepEmulation(): in headlessNeuroFrame (app.cpp:153) add syncDebug(); after neuroTick(*this); (moves refreshSerial() at 10 Hz as the windowed loop does; fill() only refills on a new serial), and in the inline headless loop after host.runFrame() (app.cpp:2556) add if (!neuroCliUrl.empty()) fillDebugViews(); (3a's hook; inline it runs on this thread). neuroHeadlessReport additionally prints the state line:
      std::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());
      
      (expose gameState to neuroHeadlessReport by moving the call inside neuro_app.cpp; it is already file-local there.)
    • fillDebugViews() (= DebugSession::fillViews()) has no gate and wakeViews() is gated only on "a core and content" (Interfaces, A3), so a Tools-window state never starves the watches; debug.views() is reachable while debug.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-failure
    

    Expected: PASS; [neuro] state: ... cheat byte: 0xAB ... in the log. If the profile does not match, print titleOf's result once (the fixture title must match case-insensitively at the start of the title the library derives from yarc.j64; adjust titles to 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; the inputPoll comment from C2)
  • Modify: app/cmake/VJNeuroTests.cmake (header comment :1-8; neuro_off_threaded :26-31)
  • Modify: app/tests/neuro_test.cpp (loopback-threaded mode)
  • 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_threaded block (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.cpp main: 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 keeps configure, absorb(snap.agentProgress()), tick, publishTo every 5 ms; a second thread loops pollAgent(rep.snap, rep.player) every 1250 us (four polls per UI tick, standing in for fast-forward) and writes the rightNow/upNow atomics 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)), rightNow false after the server drops, upNow false 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: delete if (app.emuThreadThisRun()) s.enabled = false; (:52) and its comment line in the header of settingsFrom ("and the emulation thread" -> removed); delete the neuroInit log block (:111-112); delete the hint (:171). Rewrite the three stale comments: neuro_agent.h threads paragraph -> "tick, configure, publishTo and absorb run on the UI thread; the buttons are played by AgentPlayer on 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.cpp header 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 after ach.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) call neuroHeadlessReport(*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 via syncDebug() inside headlessNeuroFrame.

  • Step 4: run, expect PASS.

    cmake --build build -j4 && ctest --test-dir build -R "neuro|emu_thread_e2e|input_snapshot" --output-on-failure
    

    Expected: neuro_unit, neuro_loopback, neuro_loopback_threaded, neuro_app_inline, neuro_on_threaded (log shows 30 frames of input played and emulation thread)), neuro_watch_inline, neuro_watch_threaded, neuro_core_e2e PASS; no neuro_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-failure
    

    Expected: all PASS, tsan.supp unchanged (git diff --stat app/tests/tsan.supp empty). The reports to expect if something is wrong: AgentPlayer/InputQueue touched from the UI thread (it is emulation-only), or GameState read racing neuroWatchBytes (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, so press_buttons frames=3 is 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 neuro list -> "neuro_unit, neuro_loopback and neuro_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) and neuro_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 the neuro_off_threaded entry (grep it; if absent, add the line under the emu_thread list) to the three threaded names.
    • VJNeuroTests.cmake header comment: replace the neuro_off_threaded line by the six lines of the new tests.
    • app/src/input.h, input.cpp, app.cpp:476 comments are already gone with C2; grep -rn "agentControls\|off for this run\|neuro_off_threaded" app docs must be empty except docs/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 uptime on 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 run build/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, no owner violation, no ThreadSanitizer. 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.md
    

    Body (write it to that file first): what changed (the program/replica design, lineage/firstFrame, watches as a DebugViews request, 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: all App * jobs and ThreadSanitizer (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 after emu_thread_start_fallback, as of abc9054e ~:177; and update the header comment list: delete the *_threaded limits line)

Interfaces:

  • Consumes: the merged 3a-3c tree (the symbols and test names removed or renamed there).

  • Produces: ctest no_threaded_limits (label emu_thread; registered only when a bash exists, 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); bad prints 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:197 and the usage text at :27-33 (flag --perf-halt, --show-window tools), app/src/app.h:104 (bool perfHalt = false; beside perfPause), app/src/app.cpp:2021 (the tools branch, in the --show-window chain) and :2077 (beside the perfPause line)
  • 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> for memory 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 (when debug.enabled() is false), [perf] resumed.
  • Step 1: Implement the flags.

    • --show-window tools: in the chain at app.cpp:2021 add else if (a.showWindow == "tools") openAllTools(""); where openAllTools(const char* suffix) is a lambda in runInteractive looping the eight ids above: openDebugWindow(debug, id) then fprintf(stderr, "[app] tool %s open=%d%s\n", id, tool && tool->isOpen && tool->isOpen(), suffix). The !emuThreadThisRun_ && guard on the old openDebugWindow line is already gone in 3a; this branch must not reintroduce it.
    • --perf-halt, next to the perfPause line: at perf.seen == 120: if debug.enabled() then debug.breakNow() and log [perf] halt requested, else log [perf] halt unavailable; at == 240: openAllTools(" (halted)") and log [perf] halted=%d from debug.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 of emu_thread_windowed_check.sh (same VJ_WINDOWED_OK=1 refusal, same fail accumulator, prints emu_thread_gate_check: OK). Every run greps its log for owner 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, then VJ_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=1 lines and eight open=1 (halted) lines; [perf] halted=1 and [perf] resumed, no halt unavailable; $out/neuro.txt ends with ok presses=<n> where n >= 5. CSV (columns iter_ms,emu_frames,..., one row per UI iteration, header skipped): rows 130-410 all have emu_frames==0 (the emulation is parked), their median iter_ms <= 17.5 and p99 <= 25 (60 Hz Studio display; GATE_MEDIAN_MS / GATE_P99_MS override for another refresh), and rows 440-720 have emu_frames>0 in 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 tools and VJ_PERF_CSV="$out/b.csv", then python3 app/tests/emu_thread_gate_gdb.py 22349 5.0 (below), then wait for the app. Assert: probe exit 0; app exit 0 within 20 s; eight open=1; the longest run of consecutive emu_frames==0 rows in b.csv is >= 200 and, after dropping its first 30 rows (the load), has median <= 17.5 and p99 <= 25; and emu_frames>0 in at least 100 rows after it.

    Run C, plain. Same flags as the existing windowed check's on run plus --show-window tools, 600 frames, to prove the new default path presents with every tool open and nothing halted: >= 250 rows with emu_frames>0.

    emu_thread_gate_gdb.py <port> <hold-seconds>: sys.path.insert(0, <repo>/test/tools) and import gdb_breakpoint_probe as p for cksum/pack/recv_packet/cmd. Connect (retry 100 x 0.1 s), send +, qSupported, QStartNoAckMode; the virtualjaguar_gdb_wait halt means the 68K is stopped at its first instruction, so: g (registers, length > 0), m4,8 (reply is 16 hex digits), Z0,1000,2 then z0,1000,2 (both OK); sleep <hold-seconds> printing holding halt; D (detach, which also clears breakpoints) or c; exit 0 printing gdb gate: OK. Any non-OK reply 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's startup message and its actions/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}"}}; count action/result replies with "success":true; at the end write ok presses=<n> (or FAIL presses=<n>) to the result file and exit. Randy is not used: the Studio has node but 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.sh gates 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 target vj_build_channel after the VJ_DISCORD_APP_ID block, as of abc9054e ~:72-76; link it into virtualjaguar in the block at ~:457 that defines VJ_APP_VERSION), app/src/app.cpp:282 (emuThreadWanted), app/src/app.h:245-249 (comment), app/cmake/VJEmuThreadTests.cmake

Interfaces:

  • Consumes: ui.emu_thread read at app.cpp:211 (emuThreadPref -1/0/1).

  • Produces: build_channel.h exactly 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 target vj_build_channel (INTERFACE) carrying VJ_BUILD_CHANNEL="<channel>" and VJ_EMU_THREAD_DEFAULT=<1|0>; ctest emu_thread_default_unit.

  • Step 1: Write the failing test app/tests/emu_thread_default_test.cpp (same CHECK / FAIL style as emu_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.cpp count +1)
  • No change to app/src/settings_reset.cpp: ui.emu_thread is already KeyClass::Interface (settings_reset.cpp:39) and saveConfig erases the key while the choice is -1, so Reset restores the channel default; settings_reset_test.cpp already 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 the emu_thread_default_unit block). 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-script override (~:1943), before if (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 (meta job 76-128; the three cmake -S app invocations ~:193, ~:370, ~:638; the three job env: blocks ~:173, ~:353, ~:610; a new step in the flatpak job 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-598 needs no change: it already passes release_tag (nightly on develop pushes, prerelease on release/**, the v* tag on tags) and app-package.yml derives the channel from that.

Interfaces:

  • Consumes: release_tag (nightly | prerelease | v* | pr<N> | dev), needs.meta.outputs.label.

  • Produces: meta output channel (nightly -> nightly, v[0-9]* -> release, anything else -> dev; prerelease and PR builds are dev, 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 to nightly by the flatpak job when label == nightly; one app.yml row configured -DVJ_BUILD_CHANNEL=nightly.

  • Step 1: meta emits the channel. In the case "$TAG": the v[0-9]*) branch gains channel=release; the *) branch gains case "$TAG" in nightly) channel=nightly ;; *) channel=dev ;; esac. Add echo "channel=$channel" to the { ...; } >> "$GITHUB_OUTPUT" group, channel to the echo at the end, and channel: ${{ steps.m.outputs.channel }} under outputs:.

  • Step 2: Pass it to the three configures the way SHORT_VERSION is passed (a job env var, never an inline ${{ }} in the script). In each of the macOS (:173), Windows (:353) and Linux (~:610) job env: blocks add CHANNEL: ${{ 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.yml matrix, after the Linux x86_64 row:
          # 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. dev builds (pull-request artifacts, local builds) default to off; -DVJ_BUILD_CHANNEL=nightly builds 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 with no_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 for no_threaded_limits (what each of its six checks forbids, how to raise the allowlist honestly), emu_thread_default_unit and emu_thread_default_smoke_* (why four: a headless run is inline, so the stored-choice line is what pins the rule), the nightly-channel app.yml row, and emu_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 is nightly only, dev is Off, and prerelease builds are dev. The flatpak job stamps the channel by sed because the manifest's config-opts are 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