* 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]>
31 KiB
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)
- Poll SDL events; publish an
InputSnapshotafter every input event. - Take the newest frame from
FrameExchange(redraw the previous one if nothing is new). - Drain
EmuSnapshot(messages, events, option changes, debug results). - Draw ImGui and present with the user's vsync setting.
The UI never waits on emulation.
Emulation thread (one emulated frame per iteration)
- Wait until
PacingClocksays the next frame is due (or for a command while paused or halted). - Apply queued commands.
- Run one frame. The core's input-poll callback copies the newest input snapshot. Audio goes straight into the SDL
audio stream (
SDL_PutAudioStreamDatais safe from any thread). - 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,
PacingClocksteps 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 offturns 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 atqos_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 thatLatchWaker(app/src/latch_waker.h) pushes from its own user-interactive thread, with SDL's timeout kept as a 5 ms backstop. ACFRunLoopTimeron the main run loop was tried instead, and from a terminal launch it fired ~3 ms late every time.--latch-wake plainrestores the old wait for A/B runs (the QoS raise stays), and withVJ_PERF_TRACEthe 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: macOSTHREAD_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), WindowsTHREAD_PRIORITY_TIME_CRITICALplus the MMCSS "Games" task, LinuxSCHED_RRat a low real-time priority whereRLIMIT_RTPRIOor 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 normalskips it for one run (A/B);EmuThread::realtimeNote()andemu_thread_unit'stestRealtimeNotecover 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::updatecontroller). - 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 withupdate(true); mode A sets the base back to 1 and passesupdate(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.
- The measured period is
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'sonCoreLog/onEventbecome 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 asCoreEvents in one ordered queue; the UI thread reads them when it pins the newest snapshot, andCoreHost::onUiEventis the UI-side channel they arrive on.onLogandonEventstay synchronous on the core's thread (the headless--disc-insertcheck relies on that), so they are not the UI's tap.fastForwardandmutebecome atomics; "input captured" becomes part ofInputSnapshot.audioBufferStatusreads 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.
6. Debugger halt, gdb stub, link play, run-ahead
Halt. The core halts inside retro_run (on the emulation thread). The new halt_pump:
- publishes a "halted" snapshot (target CPU, PC, reason) and pauses the audio stream;
- blocks on a condition variable for Continue / Step / Detach from the UI;
- 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 beforestart()) is called on the thread that waits incall()once it has waitedkBusyAfter(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, callcall()orpost(), or read a field that only acall()writes. It is never called inline, on the emulation thread, or re-entered.App::busyFramedrawsVideoOut::drawBusy, which replaces the picture with the spinner while the call lasts. - Content switch: unload and load are calls;
FrameExchangeandEmuSnapshotare 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, defaultdev) setsVJ_EMU_THREAD_DEFAULT(1 fornightly).app-package.ymlpasses the channel from itsrelease_tag(nightly-> nightly,v*-> release, else dev); the flatpak job stamps it into the manifest'sconfig-opts. The default applies only whileui.emu_threadis 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 isnightlyonly,devis Off,prereleasebuilds aredev. The channel is a workflow env beside the TV default; the flatpak job rewrites the manifest's committed-DVJ_BUILD_CHANNEL=releasebecause itsconfig-optsare committed. The bar that lets it ship isno_threaded_limits(static, in CI) plus the Studio gateapp/tests/emu_thread_gate_check.sh. Shipped (phase 5c, 2026-11-01): every channel defaults On (emuThreadDefaultForis true forrelease,nightlyanddev;VJ_EMU_THREAD_DEFAULT_VALUEis 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 storedui.emu_threadwins,--emu-thread offruns 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_CSVfrom #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:
EmuThread,EmuCommands,FrameExchange,InputSnapshot,PacingClockmode A; basic commands (load, unload, reset, pause, fast-forward, save/load state).- Option/message/event snapshots; achievements; cheats; netplay
afterFrame. - 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 andDebugViewswatches; 3dVJ_BUILD_CHANNELand 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). - Mode B.
- 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.