The 4.0 flip from the phase 5 plan: emuThreadDefaultFor() is true for release, nightly and dev (an unknown channel string stays Off), and VJ_EMU_THREAD_DEFAULT_VALUE is 1 for every channel. The channel still names the build in About and the log. Unchanged: a stored ui.emu_thread wins, --emu-thread off runs one start inline, a failed thread start falls back inline, headless runs stay inline unless --emu-thread on. Called by the maintainer with G1-G3 met and G4/G5 still open; the phase 5 plan and the design doc's section 8 record that. The default smoke tests now expect "default on" in every channel; the unit test pins all three channels On and unknown ones Off. Refs #1039, #967. Co-authored-by: Claude Fable 5.1 <[email protected]>
12 KiB
Emulation thread, Phase 5 plan (qualify the stable default)
Issue: #1039 (part of #967 / #254). Design of record: app-emulation-thread-design.md, section 8 "Rollout". Earlier phases: 1, 2, 3, 4.
Goal: the 4.0 release turns Settings > Interface > Emulation thread On by default, on evidence, with the inline loop still selectable. This phase is mostly qualification. The only behaviour change is the last PR, which flips the release default.
Status (2026-11-01): 5c shipped. Every channel (release, nightly, dev) defaults On. The maintainer called the flip with G1–G3 met and G4/G5 still open (see the evidence table); any G4/G5 report now lands against the default-on build.
Not in this phase:
- Retiring the inline loop (
stepEmulation,Pacer, the inline branch ofrunInteractive). #1039's rule replaces the phase 4 plan's "Retiring the inline loop (phase 5)": remove it only after a stable release has shipped default-on without regressions. That follow-up issue is filed when the evidence exists, not before. - Changing Frame pacing's default. It stays Jaguar rate unless decision 1 below says otherwise.
Where it stands (2026-10-31, develop @ fe80b36a)
- Phases 1–4 are merged. Nothing is switched off while threaded:
no_threaded_limitsenforces this statically in CI. Mode B is in (#1125, #1128). - Nightlies default On since phase 3d (about 2026-10-24); releases and
devbuilds default Off. No threading issue has been filed since. That is a week with no reports, not qualification evidence. - Headless runs stay inline whatever the default (
app.cpp, theemuThreadThisRun_decision). Flipping a channel's default therefore changes no existing test's mode, and the inline tests keep covering the fallback. - CI coverage today:
- 82 tests carry
LABELS emu_thread. They run in all sevenapp.ymlconfigurations: macOS arm64, Linux x86_64 (dev and nightly channel), Linux aarch64, Windows x86_64 MinGW, Windows ARM64, and MSVC (experimental). - The TSan job runs
ctest -L emu_threadon Linux. On the merged mode B code, 82 of 82 pass, re-run on the Studio.
- 82 tests carry
- Windowed coverage today: the Studio's 60 Hz panel (macOS) only. Headless CI never has vsync, so the late latch and mode B are exercised on Windows and Linux only by unit tests with fake clocks.
Gates
Each gate lists its evidence. Gates 1–3 are work in this repo; gates 4 and 5 are evidence that has to come in. The flip (PR 5c) opens only when every gate is met or the maintainer waives it in writing on #1039.
| Gate | Met when | Evidence goes to |
|---|---|---|
| G1 coverage | Every headless test that runs the app with content has a _threaded twin, or a line here saying why it is mode-specific |
PR 5a |
| G2 platforms | G1's twins pass on every app.yml configuration: the six required ones, plus MSVC, which may stay experimental. TSan stays green |
PR 5a CI |
| G3 Studio, 60 Hz | The phase 4 measurement passes its bar (below) and the transition runs recover. The phase 3 Studio gate (emu_thread_gate_check.sh) passes on the 4.0 candidate |
#1128 comment, then the evidence table here |
| G4 other displays | At least one windowed report each for Windows, Linux and a 120 Hz panel, threaded, with no regression against inline | #1039 comments |
| G5 nightly window | Two weeks of nightlies with no open threading regression; any report is fixed or shown not to be a threading issue | #1039 |
G3 bar (phase 4, Task 8):
- Mode B event→present within ~2 ms of inline on every title, Cybermorph included.
- No periodic repeat in mode B.
- No new underruns.
- CPU within +5% of mode A.
- PAL reports the
ratiofallback.
Mode A must not regress against the phase 1 numbers.
G1: tests without a threaded twin
Taken from ctest --show-only=json-v1 at fe80b36a. These are tests whose command runs virtualjaguar with content and passes no --emu-thread, and that have no _threaded twin.
| Area | Tests | Twin |
|---|---|---|
| Smoke | smoke_cd, smoke_boot_cart_rom, smoke_disc_insert_cart_hidden, smoke_bad_option, smoke_locked_option |
add |
| Cheats | cheat_state, cheat_apply_control, cheat_persist_{save,restart,control}, cheat_tempest_{lives,control}, cheat_community_install |
add. The persist chain needs its own data directory and fixtures |
| Overlay | controls_overlay_{default,keyboard,dismissed,rebound} |
add |
| Netplay | netplay_pair_shared |
add (the other pair tests already have one) |
Mode-specific by design, so no twin:
gdb_inline_halt*,neuro_app_inline,netplay_pair_mixed(one peer each way),emu_thread_default_smoke_*.tv_menu_headlessandtv_script_windowed_only: a headless TV script always runs inline (app.cpp).native_menu_dump: no content.netplay_discovery: skipped in CI.
The *_e2e tests (input_e2e, devices_e2e, bios_e2e, debug_scan_e2e, debug_op_e2e) drive CoreHost directly, not the app loop. Their threaded counterparts are core_host_thread and emu_thread_e2e.
Shutdown. These already cover it:
emu_thread_unitcovers shutdown races.gdb_threaded_haltcovers SIGTERM mid-halt.debug_e2e_threadedcovers the 3 s join while halted.
5a adds one headless case: quit while a slow load call() is in flight (VJ_TEST_SLOW_CALL_MS, as smoke_busy_threaded uses). It must exit 0 within the join timeout with no owner violation.
PRs
- 5a
test(app): threaded twins (G1, G2).- The twins listed above go through the registration each area already uses: the
_threadedloop inVJEmuThreadTests.cmake, the twin helper inVJAchTests.cmake, or the same pattern added toVJCheatTests.cmakeandVJSmokeTests.cmake. - Each twin carries
LABELS emu_thread, so the TSan job runs it. - Each twin must reach the same pass regex as its inline original. A twin that needs a different expectation is a finding, not a test tweak.
- Fix any real failure in its own PR, outside 5a.
- Refs #1039.
- The twins listed above go through the registration each area already uses: the
- 5b
docs(app): qualification record.- The evidence table below, filled from G3–G5.
- A short "Reporting a threading problem" note in the README's "Emulation thread" section: how to report (the About window's version and channel line, the perf note,
--emu-thread offto compare). - One pinned comment on #1039 asking nightly users for G4 reports.
- Refs #1039.
- 5c
feat(app): releases default the emulation thread On (the flip, last). Shipped 2026-11-01, withdevOn too (decision 2).emuThreadDefaultFor("release")returns true.VJ_EMU_THREAD_DEFAULT_VALUEfollows inapp/CMakeLists.txt, and thestatic_assertinbuild_channel.hand theemu_thread_default_unit/emu_thread_default_smoke_*expectations change with it.- Unchanged:
- a stored
ui.emu_threadchoice still wins; --emu-thread offstill runs inline for one run;- a failed thread start still falls back inline.
- a stored
- Docs: design doc section 8 "Shipped (phase 5)" and the README.
- The 4.0 release notes say how to turn the thread off.
- Closes #1039 (and #967 if the maintainer agrees that the retirement follow-up is filed separately).
Decisions for the maintainer
| Question | Recommendation | |
|---|---|---|
| 1 | Frame pacing default: keep Jaguar rate for 4.0, or default nightlies to Match display now so feedback accrues? | Default nightlies to Match display after G3 passes; keep Jaguar rate for the 4.0 release. Mode B's only windowed evidence is one 60 Hz macOS panel. |
| 2 | dev channel after the flip: Off (today) or On? |
On, with 5c. Phase 3 kept dev Off so PR artifacts behave like releases; after the flip that same reason means On. Taken: 5c sets all three channels On. |
| 3 | G4 without volunteers: if no Windows, Linux or 120 Hz report arrives, does the flip wait? | Wait for Windows (the largest user base), or waive in writing on #1039. Linux and 120 Hz can ship on unit-test coverage plus the inline fallback. |
| 4 | G5 length: two weeks of nightlies? | Two weeks, starting when 5a merges, so the twins have run on every PR in that time. |
Evidence
Filled in by 5b, then kept up to date as G3–G5 evidence arrives.
Studio = the Mac Studio (M4 Max, 16 cores) on its 60 Hz panel, every windowed run launched through LaunchServices (#1026), 60 s per run with scripted input, started only at a 1-minute load under 4 (the host's iCloud daemons hold about 2.7 cores). Arms: off = inline; A = threaded, Jaguar rate; B = threaded, Match display. Event→present is the exact figure, about 11,100 samples per run. Traces are in vj-eval/1039-g3/.
| Date | Build | Host / display | What | Result |
|---|---|---|---|---|
| 2026-10-31 | 06fa8a71 (#1130) |
GitHub CI, every app.yml configuration |
G1, G2: the threaded twins | Met. 21 twins; emu_thread label 82 → 102 tests. All seven app configurations pass, MSVC included, and so does the ThreadSanitizer job |
| 2026-10-31 | a661028c (fe80b36a's code) | Studio | G3 bar: off / A / B / B / A / off on yarc, Cybermorph, yarc PAL (mb-m3-*) |
Met. B − off event→present: yarc −0.18, Cybermorph +0.16, PAL +0.99 ms. 0 underruns in 18 runs. CPU against A: −9%, −6%, +1.5%. PAL reports ratio. Full tables: #1128 comment |
| 2026-10-31 | a661028c | Studio | G3 transitions, Match display, yarc (mbt-t3*) |
Met. sleep: B → A (no_vsync) → B, 0 underruns. fs: locks to B. link: both copies mode A (link). halt (5 s, then Continue): ends B locked, ticks dropped 0, 1 underrun across the halt |
| 2026-10-31 | 1b18d491 (phase 1) vs 43383a05 (develop) |
Studio | G3 "mode A vs phase 1": A and off, yarc and Cybermorph, builds in order p1 dv dv p1 (mb-p5-*) |
Met: no mode A regression. A − off event→present, p1 → dv: yarc +1.96 → −1.04 ms, Cybermorph +1.94 → +2.47 ms (dv's two runs: +1.5, +3.4). Mode A repeats in 60 s: yarc 26, 16 → 3, 1; Cybermorph 25, 19 → 64, 58. Inline Cybermorph rose as much (21, 15 → 46, 51), with the same change in frame cost in both arms (p1 4.8 → 3.0 ms, end-due spread 5.3 → 7.5 ms): the core or content changed between the builds, not the thread. A ÷ off repeats is about 1.2 on both builds. Both builds' traces read by the same summary script |
| 2026-10-31 | 43383a05 |
Studio | G3 halt, inline arm | Not measurable. Inline, a halt runs in the core's nested haltedFrame loop; --perf-halt's Continue is in the outer loop, so it never comes. That nested loop still runs the UI, so the debugger's Continue button works. Run stopped after 1 h 40 min |
| 2026-10-31 | 43383a05 |
Studio | G3 halt, A vs B (mbt-p5-{A,B}-halt) |
Met. A 5 s halt with all eight Tools windows open, then Continue: 0 underruns at Jaguar rate and at Match display. Match display ends B (locked), ticks dropped 0. The m3 run's single underrun did not come back |
| 2026-10-31 → 11-01 | 43383a05 |
Studio | G3 phase 3 gate (emu_thread_gate_check.sh, yarc; p5-gate*) |
Met on a quiet host, to be run again on the 4.0 candidate. Two quiet runs pass: halted iteration p99 18.32 and 18.15 ms in part A, 18.35 and 17.57 ms in part B (limit 25 ms; phase 3 measured 18.0–20.7). Neuro ok. One run under load failed part A at p99 33.08 ms (10 of 281 halted iterations missed a vsync): the host's 1-minute load rose from 3.6 to 7.0 during it. One run never got a quiet host (load 89, Spotlight) and was skipped |
| Windows, Linux, 120 Hz | G4 | Pending: request on #1039. Not waited for: the maintainer called the flip on 2026-11-01 (5c) | ||
| 2026-10-31 → 2026-11-14 | nightlies | G5 | Window open from the 5a merge |