Libretro Frame-Time Callbacks: Host Deltas Are Not Emulated Hardware Clocks
Use libretro frame-time callbacks with explicit fallback, microsecond units, time-manipulation boundaries, deterministic clocks, and lifecycle tests.
A libretro frontend controls when it calls retro_run(). A core may need information about the time between those calls, but that does not mean every subsystem should advance according to host elapsed time. The frame-time callback supplies timing information under a specific contract, including deliberate behavior when the frontend manipulates time. Confusing that value with the emulated machine’s hardware clock can make a core speed up, drift, or become nondeterministic.
This article uses the maintained libretro header and RetroArch runloop source reviewed on October 11, 2026. Header-defined behavior and observed frontend implementation are identified separately. Pin the header and frontend revision used in an actual integration; the example is a small timing-input adapter, not a complete emulator scheduler.
Register an optional callback, not a new main loop
RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK takes a pointer to a retro_frame_time_callback structure. The structure contains the callback function and a reference duration. The callback receives a retro_usec_t, a signed 64-bit count expressed in microseconds.
The environment call returns false if the frontend does not make the facility available. The header explicitly allows frontends to disable it in some situations. A core must therefore have a useful behavior without this input. A successful registration in one test frontend is not proof that every execution mode or frontend will deliver it.
There are two different null cases. A valid structure with a null callback requests clearing the registered callback. Passing a null structure pointer to the environment call is undefined behavior under the header contract. Do not use a null payload as a feature probe just because another environment command permits that convention.
Understand the reference duration
The reference field is the ideal duration of a frame, calculated as one million divided by the core’s frames per second. The API expresses that duration as an integer number of microseconds. The header assigns handling of reference rounding for exact frame stepping to the frontend.
A 60 Hz example can use 1000000 / 60, but that example must not be copied into a core whose nominal rate is different. A console’s video rate, an arbitrary menu animation rate, and the physical display refresh rate are not automatically the same value. Derive the reference from the timing model the core actually reports, and re-evaluate it when that model legitimately changes.
The callback’s argument describes the interval associated with retro_run() iterations. It does not measure controller-to-photon latency, graphics queue residence, audio-device playback position, or every interval inside the emulated CPU. Those require different observations.
Time manipulation changes interpretation
The header says that when the frontend is not running in real time, such as frame stepping or slow motion, it supplies the reference value instead of ordinary elapsed time. The callback therefore must not be treated as a guaranteed raw wall-clock measurement. Fast-forwarding can also make a received reference interval differ greatly from the host time used to execute the iteration.
The current RetroArch implementation provides additional detail, but that detail is not a portable promise for every frontend. Its runloop uses the reference when the previous timestamp is absent or a locked-frame condition applies. Its unlocked path calculates a timestamp difference and adjusts it when slow motion is active. The precise conditions matter when investigating a particular build.
When the header’s general description and an implementation’s branches differ in detail, record the implementation behavior in your compatibility tests rather than silently redefining the public contract. Do not infer that all frontends must expose the same pause or slow-motion delta sequence from one source file.
Keep optional timing input bounded and replaceable
The following C99 adapter accepts one pending interval for use in the next iteration. It targets an illustrative 60 Hz, host-time-driven application. Its negative-value fallback and 250 millisecond cap are application policy, not requirements supplied by libretro. An accurate emulator should not apply this cap to its hardware timeline.
#include "libretro.h"
static const retro_usec_t reference_usec = 1000000 / 60;
static retro_usec_t pending_usec;
static bool pending_valid;
static void RETRO_CALLCONV receive_frame_time(retro_usec_t usec)
{
pending_usec = usec;
pending_valid = true;
}
static bool register_frame_time(retro_environment_t env)
{
struct retro_frame_time_callback timing = {
receive_frame_time, reference_usec
};
pending_valid = false;
return env(RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK, &timing);
}
static retro_usec_t consume_interval(void)
{
retro_usec_t value = pending_valid ? pending_usec : reference_usec;
pending_valid = false;
if (value < 0)
value = reference_usec;
if (value > 250000)
value = 250000;
return value;
}
The consumer clears the pending flag so an absent new callback does not reuse yesterday’s observation forever. Integrate consumption with the appropriate iteration boundary in your core. Clear pending state when resetting the application’s timing epoch or loading different content. If a separate thread shares the state, this plain adapter is insufficient: provide synchronization consistent with your architecture.
The cap prevents a host-driven animation or simulation from taking an unbounded step after a long interruption. It deliberately discards elapsed time and is unsuitable for features requiring exact elapsed-time accounting. Such features need an explicit catch-up or pause policy rather than an undocumented clamp.
Preserve the emulated clock where determinism matters
An emulator typically advances emulated CPU cycles, timers, DMA, and audio according to its machine model. If a host scheduling delay causes that model to execute extra cycles, the guest can observe a different hardware history. A fast host must not execute fewer guest cycles merely because its real-time interval is shorter.
Use frame-time input only where its semantics fit the feature. A libretro port of an application designed for elapsed-time simulation may use it directly with a documented policy. A hardware emulator may use it for diagnostics or presentation while retaining deterministic guest timing. Neither architecture is implied just by the presence of the callback.
Review save-state behavior explicitly. If a timing accumulator changes guest-visible state, it may need serialization and a reproducible restore rule. If it is host-only presentation state, restoring a long-obsolete host timestamp can be wrong. Classify the state before deciding how to persist it.
Audio notifications are a different optional interface
The header’s asynchronous-audio callback is intended for software whose audio is generated asynchronously. It is not a general acceleration switch for an emulator. Its notification can run on any thread, and the core still sends samples through normal audio callbacks. The header recommends frame-time callbacks alongside that interface, while retaining a normal audio-rendering path when asynchronous support is disabled.
Registering frame-time input does not make audio code thread-safe, establish a sample rate, or synchronize an audio device to video. Keep sample frames, channels, microseconds, and emulated cycles distinct in code and logging. A unit mismatch can appear as an unexplained speed problem even when registration is correct.
Test registration and delivery separately
With a mock environment callback, test successful registration, unavailable registration, and a valid clear request. Record the payload structure and assert that the requested reference matches the example’s documented rate. Then exercise the adapter without delivery, with an ordinary positive interval, with zero, with a negative interval, and with an unusually large interval. Those tests establish your policy’s behavior, not actual frontend timing accuracy.
In the target frontend, record callback order and values across ordinary play, pause/resume, frame stepping, fast-forward, slow motion, and content reload. Include the frontend version, core revision, timing mode, reported frame rate, and host platform. Treat expected values as hypotheses until observed; do not publish a fabricated timing trace.
If registration succeeds but behavior drifts, inspect where the core consumes the value and whether it reuses stale input. If registration fails, confirm the fallback before enabling unrelated latency settings. If only one manipulated-time mode fails, compare the pinned frontend’s runloop with the public header and build a minimal reproduction.
The defensible result is a core whose guest clock remains correct and whose optional elapsed-time behavior is documented, bounded where appropriate, and tested in both supported and unsupported paths. Receiving a timestamp is useful; deciding what that timestamp is allowed to control is the engineering work.
Related:
- The Libretro Audio Callback Contract: Frames, Buffering, and Timing
- Frame Pacing and V-Sync: Why Correct Emulator Timing Is More Than Average FPS
Sources: