Skip to content

Emulation Timing

codingncaffeine edited this page Jun 6, 2026 · 2 revisions

Emulation Timing

The Linux port runs each game in a separate game-host process. The emulation loop and the GL/Vulkan window live there; the library window only feeds it a target geometry. Presentation is a native Wayland toplevel with a vsync'd EGL swap. See Emulator/EmulatorSession.cs for the decoupled loop.

The Pacing Model (RetroArch's audio-master clock)

The loop is audio-master-clocked with dynamic rate control (DRC) — RetroArch's model. Each frame, SdlAudio.ApplyDrc() nudges the resampler frequency ratio by ≤0.5% to hold the audio input queue at a target cushion. That servo is the primary sync mechanism: the core is paced to its content rate (_fps) and audio is resampled to match, instead of letting raw buffer drain dictate the loop. Coarse watermark guards (backpressure / low-watermark catch-up) are only far-extreme backstops.

SdlAudio exposes two buffer readouts and you must use the right one for the right job:

  • QueuedMs — a wall-clock estimate (total samples enqueued minus playback time elapsed). Use it where you want a smooth time-based figure.
  • QueuedMsReal / QueuedMsSmoothed — reflect the actual device drain, so they're the correct control signal for pacing and DRC. The coarse guards servo the smoothed value so a single device gulp can't fire a spurious drain-stall.

Selectable pacing methods (EMUTASTIC_PACING)

The in-loop pacing is a lever for A/B testing on real hardware:

  • stopwatch (default) — high-res frame timer to the content rate; sleep most of the budget, spin the last ~1 ms. Audio thresholds are guards only.
  • audio — sound-clock: after each retro_run() adds ~1 frame of audio, wait for the device to drain back to the cushion. The device consumes at exactly sample_rate, so this paces the loop to real time at the core's true rate with no timer wobble.
  • spin — pure busy-spin to the budget (lowest timer wobble, burns a core).

DRC is opt-in via EMUTASTIC_DRC=1 outside the GL spike path; the default decoupled GL path applies it every frame.

HW Cores

HW cores (Dreamcast, GameCube, N64 GL path) are paced to a fixed content rate the same way. The rate comes from the console handler's HardwareTargetFps (e.g. Dreamcast forces 60) when it returns a positive value, otherwise the core's reported av_info.timing.fps. Pacing the emu to that fixed rate — rather than free-running on audio drain, which drifts toward ~61fps and beats against the ~60Hz display — keeps speed and audio correct while the present thread is vsync-paced separately and just shows the latest produced frame. A missed vblank repeats a frame instead of slowing the core.

Because the loop is rate-paced rather than tied to N audio-buffer drain steps, 30fps titles need no special-casing — a 30fps content rate is just a different targetFrameMs.

Decoupled present (default GL path)

EMUTASTIC_GL_PRESENT_THREAD (on by default) runs the core on the emu thread paced as above, while a separate present thread owns the GL window and shows the latest frame at vsync. The blocking FIFO swap on Wayland provides the vsync backpressure; re-presenting a duplicate on a slow frame is correct. This is what decouples display hitches from emulation speed. A single-threaded "swap-is-the-clock" path (EMUTASTIC_GL_PRESENT_THREAD=0) and an opt-in Vulkan overlay (EMUTASTIC_VULKAN=1) exist for A/B comparison.

No DWM / WaveOut quirks here

The Windows build had a class of bugs rooted in WaveOut draining in steps synchronized to the DWM compositor rate (the notorious "N64 locked at 48fps on a 144Hz monitor" — 144 ÷ 3 = 48 drain steps/sec). The Linux port has neither WaveOut nor DWM: audio goes through SDL3 audio streams whose drain we read directly, and presentation is our own vsync'd Wayland toplevel. The compositor-synchronized-drain failure mode therefore doesn't exist on Linux — frame pacing is governed entirely by the audio-master clock + DRC and the present thread's vsync swap described above.

Clone this wiki locally