Files
virtualjaguar-libretro/docs/app-emulation-thread-design.md
4feed33e2d feat(app): schedule the emulation thread as a real-time thread (#1026) (#1135)
* feat(app): schedule the emulation thread as a real-time thread (#967)

The frame loop is a periodic deadline job; ask the OS to schedule it as
one when the thread starts (app/src/thread_realtime.h): macOS
THREAD_TIME_CONSTRAINT_POLICY (period one frame, computation 30%,
constraint 60%, preemptible; runs ahead of every QoS class and on
performance cores, so it replaces the user-interactive QoS raise, which
stays as the fallback), Windows THREAD_PRIORITY_TIME_CRITICAL plus the
MMCSS "Games" task, Linux SCHED_RR at a low real-time priority where
RLIMIT_RTPRIO or rtkit allows it.

Why: on an M2 Max at a 1-minute load of 250-950 the same Doom gameplay
frame cost 5.4 ms with a performance core of its own and 18-20 ms
without one (58-86 involuntary context switches a frame; the game ran
at 54.6 of 60.05 fps).  Never fatal: every refusal leaves the thread as
it was, and the log line "[emu] emulation thread on; scheduling ..."
says what it got.  --emu-priority normal skips it for one run (A/B).

emu_thread_unit: testRealtimeNote (asked for by default, off on request,
note always set before start() returns).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Mark realtime variable as maybe unused

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Change library loading method for avrt.dll

Updated LoadLibraryW to LoadLibraryExW for better library search.

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-01 15:10:16 -05:00

31 KiB
Raw Permalink Blame History

Desktop app: emulation on its own thread (design)

Issue: https://github.com/libretro/virtualjaguar-libretro/issues/967 (milestone v4.0.0). Status: design approved in conversation 2026-10-15; this document is the spec for the implementation plan.

1. Goal and success criteria

The app runs retro_run() on the UI thread, so the core's cost per frame and the display's refresh are coupled. Two things follow:

  • a core frame that crosses the budget becomes a visible stall (blitter-heavy frames spike +1-7 ms; Cybermorph runs 14 ms+);
  • a debugger or gdb halt freezes the whole window.

The #956/#968 pacer work showed how fragile pacing is when emulation and presentation share one loop.

Success means:

  • No visible stutter whenever the core's average cost fits the frame budget, even when single frames spike.
  • The UI and every debug window stay responsive while the emulator is halted (app debugger or gdb stub).
  • No regression in input latency, audio (underruns), save states, link play, achievements, cheats or determinism.
  • True Jaguar speed by default (60.05 / 50.08 Hz).

Decisions taken during design:

Question Decision
Rate conflict (Jaguar 60.05 Hz vs display) A setting. Default A: true Jaguar rate, paced by the audio clock, display shows the newest frame. B: lock to the display, nudging the audio rate.
Rollout A setting, off in releases, on by default in nightlies; the old loop is removed after a release ships it on by default without regressions. Amended 2026-10-16: off in every build through phase 2; nightlies default it on from phase 3, once nothing is switched off while it runs.
Debug windows while running Live, copied once per emulated frame from the emulation thread (consistent end-of-frame state).
Input latency No regression: the emulation thread takes the newest input snapshot right before each frame. Amended 2026-10-16: in mode A that alone costs ~22 ms event to present at 60 Hz, so phase 1 adds a late latch in the UI loop (input published as it arrives, the newest frame taken just before the vblank). It matches the old loop for light core frames; heavy frames (~14 ms) stay ~9 ms behind until mode B. Plan: "Decided: input latency in mode A".
Architecture One owner thread with commands, synchronous calls and per-frame snapshots (not a global lock, not a separate process).

2. Threading rule

Only the emulation thread calls into the core: every retro_* function, the private debug interface (vj_debug_iface), and anything that reads or writes core memory. This matches existing contracts: app/src/core_host.h ("every method must be called from one thread") and src/debug/vj_debug_iface.h (calls on the thread that runs retro_run()). The core itself is single-threaded C89 with global state and is not changed.

The main thread owns SDL events, the window, GL, ImGui, the shader chain and file dialogs. Existing background workers (achievements HTTP, game hashing, RA images, library service, Discord IPC, font scan) are unchanged.

3. Components

Each unit lives in its own file under app/src/ and can be unit-tested without SDL, GL or the core.

Unit Purpose Interface (sketch)
EmuThread Owns CoreHost. Frame loop: wait until due, run commands, run one frame, publish. Inline mode (no thread) for headless runs and existing tests. start(mode), stop(), post(Command), call(fn), inlineStep()
EmuCommands Multi-producer queue of commands, applied in order at frame boundaries; call(fn) runs fn on the emulation thread and blocks the caller until done. post, call<T>(fn) -> T
FrameExchange Triple buffer of finished frames: pixels, width, height, pitch, aspect, frame serial. Writer never blocks; reader takes the newest without blocking. writeBuffer(), publish(), takeNewest() -> const Frame*
EmuSnapshot Per-frame published state: perf counters, option definitions/categories/visibility/locks (copied only when they change), OSD/log messages, core events, achievement events, audio-scope samples, and debug-view results. publish(...) (emulation), drain(...) (main)
InputSnapshot Latest input state (pads, keyboard, mouse, "input captured"). Main publishes after every input event; the core's input-poll callback copies the newest one. publish(state), latest()
PacingClock Decides when the next frame is due (mode A or B); testable with a fake clock. nextDue(now, audioQueued, vsyncTicks) -> time

CoreHost keeps its current API; the emulation thread is its only caller. Most existing code moves rather than gets rewritten.

4. Data flow

Main thread (one pass per display refresh)

  1. Poll SDL events; publish an InputSnapshot after every input event.
  2. Take the newest frame from FrameExchange (redraw the previous one if nothing is new).
  3. Drain EmuSnapshot (messages, events, option changes, debug results).
  4. Draw ImGui and present with the user's vsync setting.

The UI never waits on emulation.

Emulation thread (one emulated frame per iteration)

  1. Wait until PacingClock says the next frame is due (or for a command while paused or halted).
  2. Apply queued commands.
  3. Run one frame. The core's input-poll callback copies the newest input snapshot. Audio goes straight into the SDL audio stream (SDL_PutAudioStreamData is safe from any thread).
  4. Publish the frame and the snapshot; run the per-frame achievements check (it reads game memory).

Mode A: audio clock (default)

  • Target the SDL audio queue at audio.target_ms (64 ms today). Below target: run the next frame. Above target: sleep for the time the queue takes to drain back to target.
  • The sound card's clock is the master, so the game runs at true speed and audio never drifts. On a 60 Hz panel one frame repeats about every 20 s.
  • No audio device, muted output, or a paused stream: fall back to the wall clock at the core's period.
  • Amended 2026-10-17 (#967 evenness): phase steering. A frame ends at its due time plus its wake-up lateness and cost, so the end times spread over a window (a few ms for Cybermorph). While the slowly drifting grid carries the UI's take point through that window, each frame's own cost decides whether it makes the take. The result is a run of random repeat + skip pairs at every crossing (60-160 a minute) instead of the one skip the beat owes. While the UI has the vblank grid locked, it publishes its take time and the display period. When the next frame's end window would contain a take point, PacingClock steps the grid's phase once across it, in the direction the grid is drifting (its trimmed period, so the queue error a step leaves can never turn the steps against the drift). Only the phase moves: at most the window's width, once per crossing, with at least 30 frames between steps. The rate is still the trimmed audio rate, and the display never holds the drift back, so this is not mode B. Steering runs only where the grid creeps along the take grid (under 0.2 ms a frame: 60.05 Hz on 60, 120 or 59.94 Hz panels). At ratios like 50.08 Hz on 60 Hz, every few frames straddle a take point by construction (5:6 judder), and only mode B can change that. --pace-steer off turns steering off for A/B runs.
  • Amended 2026-10-20 (#1026): the UI thread's wake-ups. The late latch's wait must end on time, or the build starts late and the swap misses its vblank. On macOS two things made it end late. First, a launch from a terminal (and every ssh-run benchmark) starts the main thread at the default QoS rather than user-interactive, so any CPU load leaves it runnable but not running. Second, SDL_WaitEventTimeout's timeout is the Cocoa run loop's own timeout, which Core Foundation delivers from a dispatch worker at qos_class_main(), the main thread's QoS at launch. Raising the thread later does not change that worker, which runs 3-20 ms late under load while the UI thread sleeps past its deadline. So the UI thread now runs at user-interactive QoS however it was launched (platform_macos.mm). Its timed event waits (the latch's and the threaded nap's) end on a wake event that LatchWaker (app/src/latch_waker.h) pushes from its own user-interactive thread, with SDL's timeout kept as a 5 ms backstop. A CFRunLoopTimer on the main run loop was tried instead, and from a terminal launch it fired ~3 ms late every time. --latch-wake plain restores the old wait for A/B runs (the QoS raise stays), and with VJ_PERF_TRACE the stall probe (app/src/stall_probe.h) writes every wait that ends over 3 ms late to <prefix>-stall.txt, with the run state, CPU time, run-loop transitions and sampled stacks.
  • Amended 2026-11-01: the emulation thread's scheduling class (app/src/thread_realtime.h). The thread is a periodic deadline job and asks the OS to schedule it as one when it starts: macOS THREAD_TIME_CONSTRAINT_POLICY (period one frame, computation 30%, constraint 60%, preemptible; real-time threads run ahead of every QoS class and on performance cores, so it replaces the user-interactive QoS raise, which stays as the fallback), Windows THREAD_PRIORITY_TIME_CRITICAL plus the MMCSS "Games" task, Linux SCHED_RR at a low real-time priority where RLIMIT_RTPRIO or rtkit allows it. Why: on an M2 Max at a 1-minute load of 250-950 the same Doom frame cost 5.4 ms with a performance core of its own and 18-20 ms without one (58-86 involuntary context switches a frame; the game ran at 54.6 fps). Never fatal; the log's [emu] emulation thread on; scheduling ... line says what the thread got. --emu-priority normal skips it for one run (A/B); EmuThread::realtimeNote() and emu_thread_unit's testRealtimeNote cover it.

Mode B: display lock

  • The main thread sends a tick after each swap; the emulation thread runs one frame per tick (one per k ticks when the display is close to a whole multiple of the core rate, e.g. 120 Hz; see the amendment below). The audio rate is nudged ±0.5% to match (today's AudioOut::update controller).
  • If the display rate is not close to a whole multiple of the core rate, show a notice and fall back to mode A.
  • Amended 2026-10-30 (phase 4 plan, #1038): 144 Hz is not a lock case (144 / 60.05 = 2.40). Lock when |display/k − core| / core < 0.3% with k = round(display / core), measured from the vblank period: 60 / 59.94 / 120 / 119.88 Hz lock NTSC, 50 / 100 Hz lock PAL, everything else falls back. Mode A is also forced while link play is active, and in mode B the UI's latch wait ends when the tick's frame is published. Plan: app-emulation-thread-plan-phase4.md.
  • As built (phase 4b, #1038):
    • The measured period is LateLatch::measuredPeriodNs(): the tracked vblank phase's travel over 2-20 s spans, counted vblank by vblank, so a 59.94 Hz panel announced as 60 Hz reads 16.683 ms. Mode B waits for it.
    • Ticks count vblanks, not swaps. The UI publishes one snapshot after each blocking swap (tick, its vblank time, the measured period, k, the base ratio) under a mutex, after handing over the input that arrived while it built and swapped, so the tick's frame polls it, as in the single-thread loop. A UI iteration that misses a vblank moves the tick by two, and the emulation thread runs the frame it owes.
    • The emulation thread does not depend on the UI swapping. When the UI's next tick is 2 ms late, the thread starts the frame on the snapshot's grid by itself; when the snapshot is 250 ms old (a modal, a live resize, a sleeping display), it runs mode A until the UI ticks again. One non-blocking swap leaves mode B at once.
    • Wake on publish, then the last chance. The UI's latch wait ends when the tick's frame is published (an SDL event from the emulation thread). Past the latch's padded wake time it goes on waiting for that frame only, until the vblank minus the build estimate without the pad (LateLatch::lastChanceNs): a 14 ms frame (Cybermorph) finishes after the padded wake time but still fits the period, as it does inline. The pipeline model (late_latch_unit) puts mode B at 24.7 ms event to present for 5, 12 and 14 ms frames, against inline's 25.0; without the last chance a 14 ms frame takes 41.3 ms.
    • Audio: one controller at a time, switched at a frame boundary: mode B sets AudioOut::setBaseRatio((display / k) / core) and trims around it with update(true); mode A sets the base back to 1 and passes update(false). The base is republished only when the measurement moves it by more than 0.02% (each set restarts the trim).
    • Notices: one per lasting (2 s) change of a fallback reason; settling and fast-forward never show one.

Shared rules

  • Fast-forward: unthrottled or a set multiple, audio dropped (as today).
  • Pause: the emulation thread parks on a condition variable; the UI keeps running.
  • Falling behind: never run more than 2 frames late. Beyond that, drop owed time rather than burst (the #968 lesson). The audio queue absorbs short spikes.
  • Readers of the frame (state thumbnails, screenshots, frame hashes, the OP viewer) read from FrameExchange, never from the core's buffer.

5. The command / call / snapshot boundary

Classification follows the read-only audit of every UI-to-core touch point (2026-10-15).

Kind Touch points
Command (post, applied at the next frame boundary) option set/override/clear/reset; controller device per port; cheats set/reset; reset; pause/resume; fast-forward; mute; frame advance; disc eject/insert/index; BIOS path and boot target; netplay option changes; hardcore toggle
Call (call, caller waits at most one frame) load content; load with no content; boot BIOS; unload; save/load state (thumbnail from FrameExchange); SRAM flush (retro_get_memory_*); memory-map queries; debug poke/set register/breakpoints/step; texture-replacement reload
Snapshot (published per frame, drained by the UI) option definitions/categories/visibility/locks (on change: the core can redefine options inside retro_run); OSD and log messages; core events (crash watchdog, BIOS staged/rejected, link events); perf counters; audio-scope samples (single-producer ring); debug-view results

Callbacks that write app state today change to:

  • onOptionChanged (writes the config) and netplay's onCoreLog/onEvent become snapshot events applied on the main thread. The config is only ever written on the main thread. Amended 2026-10-18 (phase 2): the core-event stream, app/src/emu_snapshot.h. Log lines, vj_events and the option writes made on the owner side travel as CoreEvents in one ordered queue; the UI thread reads them when it pins the newest snapshot, and CoreHost::onUiEvent is the UI-side channel they arrive on. onLog and onEvent stay synchronous on the core's thread (the headless --disc-insert check relies on that), so they are not the UI's tap.
  • fastForward and mute become atomics; "input captured" becomes part of InputSnapshot.
  • audioBufferStatus reads the SDL stream's queued size (thread-safe) on the emulation thread.

Debug views. Each open debug window registers a request: a memory range, registers, the object list, the blitter log head, textures, the scope. After each frame the emulation thread copies just those into that tool's double buffer. Closed windows cost nothing. The g singletons in app/src/debug/*_ui.cpp read only their published buffers. Amended 2026-10-23 (phase 3d review): one exception. Neuro's watch values are copied by the emulation thread after each frame with no window open: with Neuro on (enabled, not hardcore, content loaded, a profile with watches) and watches more than 64 KiB apart, the copier reads the whole 2 MiB of RAM about 10 times a second, connected or not (docs/neuro.md). Amended 2026-10-20 (phase 3a): the debug lane and DebugViews. The command queue gets a second lane, EmuCommands::Lane::Debug, that the emulation thread drains at every frame boundary and from inside a halt; the frame lane is never drained while halted. EmuThread::debugCall / debugPost carry the one-shot actions (poke, set_reg, breakpoints, OP arm, blit-log enable, pack reload, attach/detach). The periodic reads are ViewRequests (memory range, DRAM, registers, a disassembly window, the OP snapshot, the blit-log tail) in DebugViews (app/src/debug/debug_views.h), keyed by an id the window keeps: set() with an equal request changes nothing (windows re-state theirs every draw), a different one clears that buffer and is pending at the same serial, and fill(iface, serial) serves every request whose buffer carries another serial, installing an answer only against the request revision it copied (a set() made during the fill wins). fill() runs after each frame at DebugSession::kRefreshHz while running, on every pump iteration whose serial changed while halted, and on the same thread inline, so the windows have one code path. A content switch bumps the views' epoch and every buffer is unreadable until refilled. DebugCore (app/src/debug/debug_core.h) is the emulation-thread owner of the interface; DebugSession stays the UI's object and reaches it through coreCall / corePost.

Achievements. rc_client_do_frame runs on the emulation thread after each frame. Unlock, progress and leaderboard events go to the main thread through the snapshot. The HTTP worker is unchanged. Amended 2026-10-18 (phase 2): idle and rich presence run on the emulation thread too. read_memory called off it (rc_client validates addresses while the game load completes on the UI thread) answers from the core's memory map, never from RAM. rc_client events are copied into AchEvents (app/src/ach_event.h), queued, and applied in Achievements::pump() on the UI thread.

Guards. The inFrame_ refusals in CoreHost and App mostly disappear, because commands wait for the boundary. While halted, see section 6.

Halt. The core halts inside retro_run (on the emulation thread). The new halt_pump:

  1. publishes a "halted" snapshot (target CPU, PC, reason) and pauses the audio stream;
  2. blocks on a condition variable for Continue / Step / Detach from the UI;
  3. meanwhile serves debug commands that are safe mid-frame: peek/poke, registers, disassembly, breakpoints, object-list and blitter-log snapshots, texture reload.

App::haltedFrame and its nested event loop are removed; the UI runs normally. Commands that cannot run mid-frame (load, unload, reset, save/load state, option changes) are held until Continue, and the UI says "applies after Continue". Hardcore mode still detaches the debugger. Amended 2026-10-20 (phase 3): the pump is bounded. GDBHalt() (src/debug/gdbtarget.c) keeps its own loop: socket pump, the app's halt_pump, the halt timeout, a 2 ms sleep. The core is not changed, so the app's pump (DebugCore::pump) serves the debug lane, waits at most kPumpWait (4 ms) for a verdict or a debug post, and returns VJ_DBG_RESUME_NONE; the core calls it again. Verdicts are an atomic the UI writes, honoured only for the halt they were written against (a serial). onHalt / onResume fire on the emulation thread for emulation-side effects (audio pause); the UI mirrors the HaltState once per frame (DebugSession::syncHalt) and fires its own onHaltUi for the toast and the halt bar. DebugCore::frameEnded(), after every retro_run, closes a halt the core left without one of the app's verdicts (gdb continued, the socket dropped, the halt timeout), which today leaves the app "halted" for good. App::haltedFrame runs only inline, until phase 5 retires the old loop. Held commands: frame-lane posts wait in the queue by construction; save / load state and boot refuse with a toast while halted (the app has no frame-advance command; the Objects window's step is simply unavailable while halted, opStepFor in app/src/debug/op_view.cpp); open / close / reset / disc insert / drop / quit detach first (a detach clears every breakpoint, so the next frame cannot re-halt) and then proceed. EmuThread::call takes a give-up predicate so a call posted an instant before a halt returns instead of hanging the UI behind the spinner. Amended 2026-10-22 (phase 3b): as shipped. The verdict the UI writes is tagged with the halt serial it mirrored and the pump drops one for any other halt; frameEnded() also closes a STEP the core never re-halted (stepping_), which would otherwise leave the mirror "halted" and wedge inline F9 / Detach. EmuThread::advance() has the same give-up as call(): its frame budget rides the debug lane, the wait ends when a halt begins, and advance(0) resumes the wait after Continue. beginQuit() is the shutdown entry: it sets quitting_, abortHalt answers CONTINUE within one pump wait (the host stays attached, so a gdb breakpoint hit again before the frame ends comes back to the pump instead of parking with no host), and only then does the app save, flush and unload. Amended 2026-10-23 (phase 3d review): waits and bounds the text did not state. The UI thread does wait on the emulation thread for one-shot debug actions: EmuThread::debugCall has no give-up, by design (emu_thread.h), because the Tools windows and Achievements::coreCall capture locals by reference or must not have a reply cancelled; only frame-lane call() and advance() carry the halt give-up. The bounds: the pump waits at most kPumpWait (4 ms) per iteration (debug_core.h); beginQuit() polls at most 200 ms (100 x 2 ms) for the core to leave a halt; after that every wait on the core is bounded (autoSave 1.5 s, the unload callFor 3 s, the stop join 3 s), and a wedge exits with status 3 (std::_Exit(3)) instead of hanging. The silent gdb host is not hardcore-gated (it never halts anything by itself; the app's own attach() and breakNow() stay gated by DebugSession::enabled()), and attachGdbHostOnCore re-attaches it before each frame it is missing.

Amended 2026-10-24 (#1069, #1081): leaving a halt. The app never answers the pump with DETACH: inside GDBHalt() a dropped host lets a gdb client's still-armed Z0 park the core again with nobody to pump (threaded, every frame-lane call then waits for gdb; inline, the window freezes). Every release is a CONTINUE with the host kept. Detach disarms and drops the app's breakpoint list and defers the host drop to DebugCore::frameEnded() (outside retro_run(); a new halt before then cancels it); Reset, Open, Close and the disc commands (App::releaseForCall) disarm the enabled breakpoints for the release and re-arm them before the first frame after the command, so the list survives a Reset. The give-up predicate of the call that follows fires only on a halt newer than the released one (releaseSerial()). The periodic frame-lane posts (SRAM autosave, achievements idle) are coalesced to one in flight, so a long halt leaves the queue bounded; rc_client_idle does not run while halted. docs/app-debug-tools.md, "One rule for leaving a halt".

GDB TCP stub. GDBHalt's socket loop runs on the emulation thread, so a gdb session that holds the target halted no longer freezes the window (fixes the freeze found in #936). If both gdb and the app debugger are attached, the first verdict wins, as today. The halt timeout is unchanged. Amended 2026-10-20 (phase 3): a host behind every halt. Without an app host the app cannot see a gdb halt: a call() would block until gdb continues and quit would wait out the join. So the app attaches a silent host whenever virtualjaguar_gdb_stub is enabled and content is loaded (one stored pointer), the halt bar says "HALTED by gdb", and quit resumes the core with the host kept attached. attach() needs the tools enabled, so RetroAchievements hardcore locks the stub to disabled, as it does cheats. Both are proposed decisions (phase 3 plan, "Decisions to confirm"). Amended 2026-10-22 (phase 3b): as shipped. The silent host is attached on the core side (the emulation thread) inside the load call, before any frame runs, and again before each frame it is missing (a user Detach drops it; the lane runs a posted Detach before a later attach), so a gdb breakpoint is never halted without a pump; Detach also clears the app's breakpoints. virtualjaguar_gdb_wait parks the boot without a client (the UI live, F9 or Detach continues; a client that connects later asks ?). Turning hardcore on while the stub is enabled reloads the game, the only way the lock reaches the core's socket. A headless run that gdb holds halted waits for the client or a quit signal, and SIGINT / SIGTERM end it cleanly (a second signal kills).

Link play. The core's peer wait inside retro_run (src/jerry/jlink.c) now blocks only the emulation thread. Its frame budget assumes one retro_run per frontend period, which mode A provides. Its 3 ms frontend margin becomes conservative. Netplay::afterFrame runs per emulated frame on the emulation thread; its UI-visible state goes through the snapshot. Amended 2026-10-18 (phase 2): Netplay stays a UI-thread object. It reads what the core says from the core-event stream (onUiEvent, with an epoch filter that drops what came before its last start, stop or bounce), and its settings reach the core as commands. The restart bounce that a mode change needs is counted in emulated frames (CoreHost::frameCount()), not in UI-loop iterations, so it lasts as long as the core sees it.

Run-ahead (if added later). Lives entirely on the emulation thread.

7. Lifecycle and failure handling

  • Startup: the emulation thread starts after the window and audio are up. If it cannot start, log and fall back to the single-thread loop.
  • Shutdown: detach any debugger halt, wake link play's wait, unload content through a call (flushes SRAM on the emulation thread), join. If the join takes more than 3 s, log and exit anyway.
  • Command errors: caught on the emulation thread and shown as a toast; the thread keeps running.
  • Slow calls: after 250 ms the UI shows a spinner instead of freezing. Amended 2026-10-18 (phase 2): the hook. EmuThread::setWaitHook(fn) (set before start()) is called on the thread that waits in call() once it has waited kBusyAfter (250 ms), then about every 16 ms until the call returns; the first call has tick 0. It may pump OS events and swap a frame it draws with raw GL; it must never open an ImGui frame, call call() or post(), or read a field that only a call() writes. It is never called inline, on the emulation thread, or re-entered. App::busyFrame draws VideoOut::drawBusy, which replaces the picture with the spinner while the call lasts.
  • Content switch: unload and load are calls; FrameExchange and EmuSnapshot are cleared on load, so no frame or option list from the previous game is shown.

8. Rollout

  • Settings > Interface > "Emulation thread" (On/Off). Releases default Off, nightlies default On (nightly build flag). Amended 2026-10-16: every build defaults Off through phase 2; the nightly default and its build flag arrive in phase 3. Amended 2026-10-20 (phase 3): the flag. VJ_BUILD_CHANNEL (CMake cache string: release | nightly | dev, default dev) sets VJ_EMU_THREAD_DEFAULT (1 for nightly). app-package.yml passes the channel from its release_tag (nightly -> nightly, v* -> release, else dev); the flatpak job stamps it into the manifest's config-opts. The default applies only while ui.emu_thread is unwritten (emuThreadEffective(pref, default)): an explicit Off stays Off in nightlies and an explicit On stays On in releases. Headless runs stay inline whatever the default. It ships last, in its own PR, after nothing is switched off while threaded. Shipped (phase 3d, 2026-10): as above; the default is nightly only, dev is Off, prerelease builds are dev. The channel is a workflow env beside the TV default; the flatpak job rewrites the manifest's committed -DVJ_BUILD_CHANNEL=release because its config-opts are committed. The bar that lets it ship is no_threaded_limits (static, in CI) plus the Studio gate app/tests/emu_thread_gate_check.sh. Shipped (phase 5c, 2026-11-01): every channel defaults On (emuThreadDefaultFor is true for release, nightly and dev; VJ_EMU_THREAD_DEFAULT_VALUE is 1 for all three). The maintainer called the flip before gates G4 and G5 of the phase 5 plan were met (the windowed evidence is the Studio's 60 Hz panel; see the plan's evidence table). Unchanged: a stored ui.emu_thread wins, --emu-thread off runs one start inline, a failed thread start falls back inline, and headless runs stay inline unless --emu-thread on. An unknown channel string still defaults Off.
  • The old loop stays selectable until a release ships with the setting defaulting On and no regressions; then it is removed.

9. Testing

  • Unit tests:
    • FrameExchange: never tears, always the newest frame.
    • Commands: posts run in order; a call sees earlier posts applied.
    • PacingClock: fake audio clock (mode A), fake vsync ticks (mode B), and a 60.05 Hz core on a 60 Hz panel giving one repeat in about 20 s.
    • InputSnapshot: an input event is seen by the next frame.
  • Determinism: headless runs and the existing core-host tests use inline mode. A new headless test runs threaded and must produce the same frame hashes as inline mode for the same scripted input.
  • ThreadSanitizer: a CI job runs the app tests in threaded mode; any race report fails it.
  • Existing tests:
    • debug_e2e: updated so the UI keeps drawing while halted.
    • netplay_pair*: unchanged, must pass.
    • Achievements and cheats tests: must pass.
  • Measurement (Mac Studio, threaded vs single-thread, 60 Hz and 120 Hz, using VJ_PERF_CSV from #966):
    • presentation evenness;
    • input latency (SDL event timestamp to the core's poll);
    • audio underruns;
    • CPU use.

10. Build order

Each step is its own PR, with the setting Off by default:

  1. EmuThread, EmuCommands, FrameExchange, InputSnapshot, PacingClock mode A; basic commands (load, unload, reset, pause, fast-forward, save/load state).
  2. Option/message/event snapshots; achievements; cheats; netplay afterFrame.
  3. Debugger halt redesign; debug-view requests; the gdb stub path; default On in nightlies (amended 2026-10-16). Amended 2026-10-20: four PRs. 3a debug views on the emulation thread (the debug lane, DebugViews, every Tools window; halts still off); 3b the bounded halt pump, calls while halted, the gdb stub unlocked; 3c Neuro through the input snapshot's agent program and DebugViews watches; 3d VJ_BUILD_CHANNEL and the nightly default. 3b and 3c run in parallel after 3a; 3d merges last, after a Studio gate with every tool, gdb and Neuro on. 3d merged after a green Studio gate (tracking issue #1079).
  4. Mode B.
  5. Measure the nightly feedback; then default On in a release and remove the old loop.

11. Out of scope

  • Variable-refresh presentation (present-on-new-frame): a later improvement on top of mode A.
  • Moving any work inside the core onto more threads (the core stays single-threaded).
  • Run-ahead and rollback netplay (#864): they build on this design but are separate work.