Phase 4a of the emulation thread plan (docs/app-emulation-thread-plan- phase4.md). Nothing calls these yet; 4b wires them in. - DisplayLock (new, header-only): one verdict per swap. It locks when k = round(display / core) puts display / k within 0.3% of the core's rate, measured from the vblank period: NTSC on 60 / 59.94 / 120 / 119.88 Hz, PAL on 50 / 100 Hz; 144 and 75 Hz fall back for both. Each fallback has its own reason (setting, no vsync, latch unlocked, ratio, fast-forward, link play). The display conditions must hold 120 swaps in a row to enter mode B, and B ends on the first swap they fail; fast-forward and link play switch at once. - PacingClock tick path: one frame every k ticks. More than one frame behind drops the owed ticks and runs one catch-up frame. After a drop it runs one extra frame per k ticks until the audio queue is back at its target: the frame that ends a stall and the catch-up both queue audio, leaving the queue near half the target, which the trim alone takes ~6 s to restore. Keyed on the drop, not a queue level: at a 20 ms target one frame's audio is most of the target, and a level rule fired in steady state. A catch-up frame counts as its tick's extra, so no tick runs 3 frames. The first tick call enters B; the first nextDue() after B resets the mode-A grid, so nothing owed crosses a switch. - AudioOut::setBaseRatio: the stream plays at (display / k) / core and update(true) trims +/-0.5% around it. It writes the SDL stream's ratio itself, so leaving B (base 1) really puts the stream back at 1 for mode A's update(false), which never touches it; clear() and a fresh open() return to the base, not to 1. Tests: display_lock_unit (lock table, each reason, hysteresis); pacing_clock_unit gains mode B through one loop shared with mode A: a 60.05 Hz core skips 3 frames a minute on 60 Hz and 7 on 59.94 Hz in mode A, none in mode B; k=1/k=2; a stall; refill after a 200 ms stall; steady state never refilling at audio.target_ms 20 / 64 / 200 ms with jittery pulls; A -> B -> A switches. audio_out_unit asserts the stream's own ratio. Mutating each rule fails at least one check. The plan doc gains the refill rule. Refs #1038 Co-authored-by: Claude Opus 5.5 <[email protected]>
11 KiB
Emulation thread, Phase 4 Implementation Plan (mode B: display lock)
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.
Issue: #1038 (part of #967 / #254). Design of record: app-emulation-thread-design.md, section "Mode B: display lock".
Goal: With the emulation thread on, Settings > Video > Frame pacing offers Jaguar rate (mode A, the default: audio clock, true 60.05 / 50.08 Hz) and Match display (mode B). In mode B every emulated frame starts on a display tick, the audio rate follows the display, and the UI swaps as soon as the tick's frame is ready. That removes mode A's periodic repeat (one about every 20 s on a 60 Hz panel) and closes the ~9 ms event-to-present gap phase 1 left for heavy titles such as Cybermorph. Wherever a lock is not sound, mode B says why and runs mode A.
Architecture: two PRs.
- 4a, the pure parts (no SDL, no GL, no app):
DisplayLockdecides whether and how to lock;PacingClockgains a tick-driven path;AudioOutgains a base ratio for its existing ±0.5% controller. All unit-tested with fake clocks and fake vsync ticks. - 4b, the wiring: the setting, the UI thread's tick and lock verdict, the fallback notice, link play forcing mode A, the latch waking on frame publish, perf-trace columns, docs, and the Studio measurement.
The old inline loop is untouched and ignores the setting (it is already display-paced and is removed in phase 5).
Decisions (agreed 2026-10-30)
| Question | Decision |
|---|---|
| Which panels lock | k = round(display / core). Lock when |display/k − core| / core < 0.3%, measured from the vblank period, not SDL's nominal refresh_rate. 0.3% leaves headroom inside the audio controller's ±0.5%. |
| 144 Hz | Falls back (correction to the design doc and the phase 1 follow-up text, which named it a lock case): 144 / 60.05 = 2.40. |
| 120 Hz validation | Fake-tick unit tests; real windowed runs only on the Studio's 60 Hz panel. A real 120 Hz check comes from nightly feedback. |
| Link play / netplay | Mode A is forced while Netplay::link() is not Off, with a notice; mode B resumes when the link is off. |
| Latch wake | In mode B the UI's late-latch wait also ends when the frame for the current tick is published; the latch deadline stays as the backstop. |
| Setting | Settings > Video > "Frame pacing": Jaguar rate (default) / Match display. Key video.pacing = jaguar | display. Active only with the emulation thread on. |
Lock table (core 60.0544 / 50.0801 Hz, tolerance 0.3%):
| Panel | NTSC | PAL |
|---|---|---|
| 60.00 / 59.94 Hz | k=1, −0.09% / −0.19%: lock | fall back |
| 120.00 / 119.88 Hz | k=2, −0.09% / −0.19%: lock | fall back |
| 144 Hz | fall back (+19.9%) | fall back (−4.2%) |
| 75 Hz | fall back | fall back |
| 50 / 100 Hz | fall back | k=1 / k=2, −0.16%: lock |
Behavior
Lock verdict (UI thread, every iteration). DisplayLock::evaluate takes the core period, the measured vblank period, and the conditions below. It returns {mode, k, baseRatio, reason}. Mode B needs all of:
- Setting = Match display, emulation thread on.
- Vsync wanted and allowed (
wantSync();guardSwaphas not forced interval 0 because the display sleeps, #1081). LateLatch::locked(). A swap grid that won't lock (ProMotion/VRR jitter, a compositor dropping frames) is not a clock to follow. VRR presentation stays out of scope (design section 11).- The ratio test above, with the latch's measured period.
- Not fast-forwarding, and
Netplay::link() == Off.
Hysteresis: enter B only after conditions 2–4 have held for 120 consecutive swaps (~2 s). Leave B on the first swap they fail. Re-evaluate at once on SDL_EVENT_WINDOW_DISPLAY_CHANGED, SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED and fullscreen enter/leave, which today only invalidate the cached period.
Notice. On each change of reason while the setting is Match display, show one OSD message, e.g. "Match display: using Jaguar rate (144 Hz is not a multiple of 60.05 Hz)", "(vsync is off)", "(link play)". Don't repeat it while the reason is unchanged. Settings shows the live state next to the choice.
Ticks. After latch_.onSwap(swap0, swap1) the UI publishes tick{seq, swap1} to the emulation thread, along with the verdict (EmuThread::displayTick: atomics plus cmds_.notify()). This period is published whatever --pace-steer says. Today's takePeriodNs_ is 0 unless steering is on, so mode B can't reuse it.
Emulation loop in mode B. A frame is due when seq − lastStartSeq ≥ k, and it starts at once. If more than 2 frames' worth of ticks are owed (2k), the owed ticks are dropped and counted, never run as a burst (the #968 rule, as in mode A). Pause, halt, advance(n) budgets and the command lanes are unchanged.
Dropped ticks take their audio with them. The frame that ends a stall and the catch-up frame leave the queue near half the target, and the ±0.5% trim alone would take ~6 s to restore it. So after a drop (or with the queue under a quarter of the target, as in mode A), one extra frame runs per k ticks until the queue is back at audio.target_ms. The refill is keyed on the drop, not on a queue level: at a 20 ms target one frame's audio (16.7 ms) is most of the target, and any level threshold would also fire in steady state. A catch-up frame counts as its tick's extra, so no tick ever runs three frames.
Mode switches happen only at a frame boundary:
- A→B:
PacingClockleaves the audio grid and its trim and steering stop. - B→A:
PacingClock::reset(now); the audio ratio returns to 1 and mode A's trim takes over.
Exactly one audio controller runs at a time.
Audio in mode B. The core produces samples at the display-derived rate, so the stream's frequency ratio is fed forward as baseRatio = (display/k) / core (0.9991 on a 60 Hz panel). AudioOut::update(true) then trims ±0.5% around that base to hold audio.target_ms. Mode A keeps update(false). The pitch change (≤0.3%) is inaudible.
Latch wake on publish. In mode B, FrameExchange::publish wakes the UI's latch wait through LatchWaker once the published frame belongs to the latest tick. The UI then builds and swaps at once, like the inline loop, so a frame that fits period − UI budget shows at the next vblank. The latch deadline still ends the wait if the frame is late; the UI never waits on emulation.
Files
| File | Change |
|---|---|
app/src/display_lock.h (new, header-only) |
DisplayLock (verdict, hysteresis, reasons) |
app/src/pacing_clock.h |
tick-driven path: tickDue(seq, k), owed-tick drop, mode switch with reset |
app/src/audio.h, audio.cpp |
setBaseRatio(r); update(true) trims around it |
app/src/emu_thread.h, .cpp |
displayTick, verdict atomics, mode-B wait in the loop, publish-wake hook |
app/src/app.cpp, app.h |
publish ticks + verdict after onSwap; display/fullscreen events re-evaluate; notice; link → A; wake-on-publish in latchWait |
app/src/ui.cpp, settings_reset.cpp, TV settings rows |
Frame pacing choice + live state |
app/src/perf_trace.h, app/tests/perf_trace_summary.py |
columns mode, tick_seq, ticks_dropped, lock_reason |
tests: display_lock_test.cpp (new), pacing_clock_test.cpp, audio_out_test.cpp, emu_thread_test.cpp |
below |
| docs: design doc (144 Hz correction, mode B as built), README pacing section, settings guide |
Tasks
PR 4a: pure parts
- Task 1:
DisplayLock. Write tests first (display_lock_unit):- Lock: 60.00 / 59.94 / 120 / 119.88 Hz NTSC; 50 / 100 Hz PAL.
- Fall back: 144 Hz and 75 Hz both regions, and PAL on 60 / 120 Hz.
- Each condition 2–5 alone forces A with its own reason.
- Hysteresis: 119 good swaps stay A, 120 enter B, one bad swap leaves.
- k and
baseRatiovalues. Then implement.
- Task 2:
PacingClocktick path. Tests with fake ticks:- k=1 runs one frame per tick.
- k=2 runs one frame per two ticks.
- A 3-period stall drops owed ticks and never bursts more than one catch-up frame.
- After a 200 ms stall the refill restores the queue within half a second; steady state never refills at
audio.target_ms20, 64 or 200 ms, k=1 or 2, with jittery device pulls. - A↔B switches reset correctly, with no owed time carried across.
- A 60.00 Hz panel in mode B shows no periodic repeat over a simulated 60 s, where mode A shows the expected ~3. Keep every existing mode A test green.
- Task 3:
AudioOutbase ratio.audio_out_unit(dummy driver): the base ratio is applied;update(true)trims within ±0.5% of base;update(false)leaves it untouched; leaving B restores 1.0. - Gate:
ctest -L emu_threadand the TSan job green; PR 4a opened (Refs #1038).
PR 4b: wiring
- Task 4: setting.
video.pacing, Video settings choice with live state, TV settings row, reset class, settings guide. - Task 5: ticks and verdict.
AppevaluatesDisplayLockeach iteration, publishes ticks afteronSwap, and re-evaluates on display/mode/fullscreen events;EmuThreadloop runs the mode-B path.emu_thread_unitgets a fake-tick client: frames run only on ticks; pause/halt/advance unaffected; commands still served while waiting for ticks. - Task 6: fallback notice + link play. One OSD per reason change. While
Netplay::link() != Offforce A.netplay_pair*run withvideo.pacing=displayand must match today's results. - Task 7: wake on publish.
latchWaitends on the tick's frame publish in mode B; perf-trace columns;late_latch_unitmodel case: with wake-on-publish a 14 ms frame at 60 Hz presents one period after its tick. - Task 8: Studio measurement (windowed, 60 Hz Dell, launched at user-interactive QoS per #1026, A/B/B/A order, exact build IDs, quiet-host gate, raw traces kept):
- Arms: inline, threaded mode A, threaded mode B.
- Titles: a light NTSC title, Cybermorph (heavy NTSC), and one PAL title (must report the fallback and behave like mode A).
- Report per arm: event → poll and event → present latency; repeats/skips split into true-rate beat vs missed deadline; audio underruns/overruns; CPU use; stalls from the stall probe kept apart from pacing misses.
- Pass bar:
- 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.
- Task 9: transitions. On the Studio:
- fullscreen enter/leave, display sleep/wake (#1081 path), vsync toggle and fast-forward toggle each switch mode with the right notice and recover;
- debugger halt / F10 step, GDB attach, RetroAchievements and a link-play pair still pass their existing checks with Match display selected.
- Docs: design doc "as built" amendments (144 Hz correction, wake-on-publish, link play forcing A), README pacing section. PR 4b opened (Closes #1038).
Out of scope
- Variable-refresh presentation (design section 11).
- Changing the default from Jaguar rate (phase 5 decides with nightly feedback).
- Retiring the inline loop (phase 5).
- Rare Cocoa/host-runloop outliers (#1026).