Skip to content

Emulation Timing

codingncaffeine edited this page Jun 7, 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) run in the decoupled loop, paced by the audio each retro_run actually produces — the game's own clock. The loop measures the sample-frames enqueued during the call, smooths that with an EMA, clamps it to 0.5×–4× of the nominal rate (the handler's HardwareTargetFps, e.g. Dreamcast forces 60, else the core's av_info.timing.fps), and uses the result as that iteration's frame budget. Silent scenes fall back to the nominal rate. The present thread is vsync-paced separately and shows the latest produced frame; a missed vblank repeats a frame instead of slowing the core.

This is why 30fps titles need no special-casing: a title that advances two game frames per retro_run emits ~33ms of audio per call, so its budget is ~33ms and it settles at 30 calls/sec. (Pacing to the nominal rate instead — the pre-fix behavior — flooded the audio queue at 2× realtime; see the Dreamcast page for the bug's history.)

One subtlety in the overfill backstop (the audio cushion drain): the loop enters the drain on the smoothed occupancy (so a single device gulp can't trigger a spurious stall) but exits on the live queue — the smoothed value is only refreshed at the loop top, so polling it inside the wait reads a frozen number and the wait never ends until its guard expires. That frozen read was a long-standing multi-second-stall bug, fixed alongside the audio-progress pacing.

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