Introduction
libretro-core-rs is a Rust workspace for writing libretro cores without
making core authors work directly against the C ABI.
A libretro core is a dynamic library loaded by a frontend such as RetroArch. The frontend owns the window, audio device, input mapping, and optional OpenGL context. The core owns the emulation or application state, reports metadata and AV timing, accepts content, and produces one frame of video and audio whenever the frontend calls it.
The public API is intentionally Rust-first. Normal core code should use enums,
newtypes, builders, slices, owned strings, Option, and Result instead of raw
callback tables, magic numbers, unchecked pointers, or hand-written
retro_* exports.
The project has two library crates:
libretro-core, imported aslibretro, owns theCoretrait,export_core!macro, typed libretro wrappers, runtime helpers, input polling, callback event routing, environment commands, and OpenGL symbol wrappers.libretro-diagnosticsowns optional visible diagnostics for cores that want clear failure frames instead of silent black screens.
Read the tutorial chapters in order if you are starting a core from scratch. Use the API reference when you already know the libretro concept and need exact entry points.
Useful local references:
Quick Start
This page is the route map for creating a libretro core with this workspace. Read it first, then follow the links for details when a concept becomes relevant.
Concept Overview
| Concept | What it means | Where to learn more |
|---|---|---|
| Core struct | Your persistent state: content, framebuffer, audio buffers, renderer handles, counters, and emulator/game state. The library does not provide this struct. | Core Lifecycle |
Core trait | The Rust trait your struct implements. It replaces hand-written retro_* functions. | Libretro In Rust |
export_core! | The macro that exports the libretro ABI symbols and dispatches frontend calls to your Core implementation. | Raw ABI Boundaries |
SystemInfo | Metadata such as core name, version, and supported content extensions. | Content, AV, and Timing |
ContentContract | Reusable declaration of content extensions and whether the core can start without content. | Content, AV, and Timing |
Environment | Setup and frontend-negotiation commands: content support, pixel format, options, controller info, hardware rendering, messages, services. | Environment and Core Options |
Runtime | Temporary handle passed into lifecycle methods so your core can poll input, submit frames/audio, log messages, and access runtime frontend services. Do not store it. | Runtime Video and Audio |
SystemAvInfo | Video geometry, FPS, and audio sample rate reported to the frontend. | Content, AV, and Timing |
| Frame loop | The work done in Core::run: poll input, advance one frame of state, submit video and audio. | Frame Loop Basics |
| Hardware rendering | Optional OpenGL/GLES context negotiation and typed Gl rendering. | OpenGL |
Step By Step
-
Create a Rust library crate and configure it as a
cdylib. A libretro frontend loads a dynamic library, not a normal executable. See Hello World Core and Running A Core. -
Define your core state struct. Put persistent data here: framebuffers, loaded content, emulation state, audio scratch buffers, GL handles, diagnostics, and counters. See Core Lifecycle.
-
Implement the
Coretrait for your struct. Start withsystem_info,av_info,load_game, andrun. Add optional lifecycle methods only when needed. See Libretro In Rust. -
Describe content support with
ContentContract. Use it in bothsystem_infoandon_set_environmentso metadata and frontend negotiation stay consistent. See Content, AV, and Timing. -
Report video and audio timing with
SystemAvInfo. Most fixed-size cores can usefixed_system_av_info(width, height, fps, sample_rate). See Audio for sample pacing. -
Use
Environmentduring setup. Register content support, input descriptors, controller info, core options, pixel format, hardware rendering, or frontend services fromon_set_environmentandload_game. See Environment and Core Options. -
Accept or reject content in
load_game. InspectGameInfoif content was supplied, initialize core state, and returntrueonly when the core can run. See Hello World Core for no-content startup and Software Core for a reusable minimal lifecycle. -
Write the frame loop in
run. Use the suppliedRuntime: callpoll_input, read input, advance one frame, and submit video plus audio. KeepRuntimeout of your core struct. See Runtime Video and Audio, Frame Loop Basics, and Input. -
Export the core. End the library with
libretro::export_core!(MyCore::default())or another constructor that creates the initial core state. See Raw ABI Boundaries. -
Validate and run it. Use
cargo test --workspacein this repo, build the core as a dynamic library, then load it in a frontend. See Running A Core and Publishing and Validation. -
Add optional systems as the core grows. Input descriptors, core options, memory maps, disks, save states, VFS, sensors, camera, microphone, MIDI, performance counters, and OpenGL all have focused chapters in the API reference.
The canonical minimal software pattern is examples/software-libretro. It
keeps the framebuffer and silent audio batch in core state so run can avoid
per-frame allocation:
use libretro::{
ContentContract, Core, Environment, GameInfo, Runtime, SystemAvInfo,
SystemInfo, fixed_system_av_info, silent_stereo_frames_for_video_frame,
};
struct MyCore {
frame: Vec<u16>,
silence: Vec<[i16; 2]>,
}
impl Default for MyCore {
fn default() -> Self {
Self {
frame: vec![0x001f; 320 * 240],
silence: silent_stereo_frames_for_video_frame(48_000, 60),
}
}
}
impl Core for MyCore {
fn system_info(&self) -> SystemInfo {
let mut info = SystemInfo::new("my-core", "0.1.0");
ContentContract::new("bin")
.with_support_no_game(true)
.apply_to_system_info(&mut info);
info
}
fn av_info(&self) -> SystemAvInfo {
fixed_system_av_info(320, 240, 60.0, 48_000.0)
}
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = ContentContract::new("bin")
.with_support_no_game(true)
.register_environment(env);
}
fn load_game(&mut self, _game: Option<GameInfo<'_>>, _runtime: &mut Runtime<'_>) -> bool {
true
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
let pitch = 320 * core::mem::size_of::<u16>();
let _ = runtime.video_refresh_frame_with_audio(
&self.frame,
320,
240,
pitch,
&self.silence,
);
}
}
libretro::export_core!(MyCore::default());
Use the hello-world tutorial for the first buildable core, the software example for the smallest complete reusable lifecycle, and the modern OpenGL example when you need hardware rendering.
Libretro In Rust
Libretro is a frontend/core boundary. The frontend loads a dynamic library, installs callbacks for video, audio, input, and environment commands, then calls the core one video frame at a time.
In C, a core implements many retro_* symbols. In libretro-core-rs, you
implement Core and let export_core! provide those symbols.
What You Implement
The game, emulator, or app code defines its own struct. That struct is the core’s state: framebuffer memory, loaded content, emulation state, GL handles, audio buffers, input state, timers, diagnostics, and anything else that must live across frames.
struct MyCore {
frame: Vec<u16>,
silence: Vec<[i16; 2]>,
frame_index: u64,
}
The library does not provide a concrete core struct for you to fill in. Instead
it provides the Core trait. Your struct implements that trait:
impl Core for MyCore {
fn system_info(&self) -> SystemInfo {
SystemInfo::new("my-core", "0.1.0")
}
fn av_info(&self) -> SystemAvInfo {
fixed_system_av_info(320, 240, 60.0, 48_000.0)
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
self.frame_index = self.frame_index.wrapping_add(1);
}
}
Core is a trait because every core has different state and behavior. The
trait is the typed Rust contract that replaces hand-written retro_* ABI
functions.
What The Library Owns
The library owns the libretro ABI boundary. libretro::export_core! generates
the exported C symbols that frontends look for, stores your core instance, calls
your trait methods at the right time, converts raw frontend callbacks into typed
Rust handles, and catches panics before they cross the ABI boundary.
libretro::export_core!(MyCore::default());
The frontend still controls when methods are called. Your code controls what happens inside those methods.
Runtime
Runtime is a short-lived handle passed by the library into methods that can
interact with the running frontend. Most cores first meet it in load_game and
run:
fn load_game(&mut self, game: Option<GameInfo<'_>>, runtime: &mut Runtime<'_>) -> bool {
runtime.logger().info("loading content");
game.is_some()
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
let _ = runtime.video_refresh_frame_with_audio(
&self.frame,
320,
240,
320 * core::mem::size_of::<u16>(),
&self.silence,
);
}
Runtime is not a game engine runtime and it is not global application state.
It is the typed access point for frontend services that are valid during the
current callback. Use your core struct for persistent state; use Runtime to
talk to the frontend.
Common Runtime jobs:
- Poll input before reading controller, keyboard, mouse, pointer, or lightgun state.
- Submit software frames, hardware frames, duplicate frames, and audio batches.
- Query the current hardware framebuffer after hardware rendering is active.
- Send frontend messages and logging output.
- Access environment commands that are valid at runtime.
- Use optional frontend services such as rumble, LEDs, sensors, camera, microphone, MIDI, VFS, performance counters, and netplay helpers.
| Libretro concept | Rust API |
|---|---|
| Core metadata | Core::system_info, SystemInfo, ContentContract |
| Environment callback | Core::on_set_environment, Environment |
| Content loading | Core::load_game, GameInfo |
| AV information | Core::av_info, SystemAvInfo |
| Per-frame execution | Core::run, Runtime |
| Hardware context reset | Core::hw_context_reset, Gl::init |
| Hardware context destroy | Core::hw_context_destroy |
| ABI symbol exports | libretro::export_core! |
Environment is available during setup and through Runtime::environment().
Use it for frontend negotiation: content support, pixel format, hardware
rendering, input descriptors, controller info, core options, messages, and
optional services.
Runtime is the per-frame handle. Use it to poll input, submit video and
audio, query hardware framebuffers, show messages, access logging, and use
frontend services that are valid while the core is running.
The normal lifecycle is:
- The frontend asks for
system_info. - The frontend supplies the environment callback, which dispatches
on_set_environment. - The frontend calls
load_game. - The frontend asks for
av_info. - The frontend repeatedly calls
run. - The frontend later calls
unload_gameanddeinit.
Hardware-rendered cores add hw_context_reset and hw_context_destroy. The
frontend owns the context and may recreate it, so GL symbols and GL object
handles are context-lifetime state.
Hello World Core
This chapter builds the first core: a no-content dynamic library that paints a blue 320x240 software frame at 60 FPS and submits silent stereo audio at 48 kHz.
Cargo Setup
Configure the crate as a cdylib. A libretro frontend loads a dynamic library;
cargo run is not the execution model.
[package]
name = "hello-libretro"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
libretro = { package = "libretro-core", version = "0.1" }
Inside this workspace, the example uses a path dependency instead:
libretro = { package = "libretro-core", path = "../../crates/libretro-core" }
Core Code
The reusable shape is the same as examples/software-libretro: keep frame and
audio buffers in core state, use one content contract helper, and submit one
video frame plus one audio batch from run.
use libretro::{
ContentContract, Core, Environment, GameInfo, Runtime, SystemAvInfo,
SystemInfo, fixed_system_av_info, silent_stereo_frames_for_video_frame,
};
const WIDTH: u32 = 320;
const HEIGHT: u32 = 240;
const FPS_HZ: u32 = 60;
const SAMPLE_RATE_HZ: u32 = 48_000;
const BLUE_0RGB1555: u16 = 0x001f;
struct HelloCore {
frame: Vec<u16>,
silence: Vec<[i16; 2]>,
}
impl Default for HelloCore {
fn default() -> Self {
Self {
frame: vec![BLUE_0RGB1555; (WIDTH * HEIGHT) as usize],
silence: silent_stereo_frames_for_video_frame(SAMPLE_RATE_HZ, FPS_HZ),
}
}
}
impl Core for HelloCore {
fn system_info(&self) -> SystemInfo {
let mut info = SystemInfo::new("hello-libretro", env!("CARGO_PKG_VERSION"));
content_contract().apply_to_system_info(&mut info);
info
}
fn av_info(&self) -> SystemAvInfo {
fixed_system_av_info(WIDTH, HEIGHT, FPS_HZ as f64, SAMPLE_RATE_HZ as f64)
}
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = content_contract().register_environment(env);
}
fn load_game(&mut self, _game: Option<GameInfo<'_>>, _runtime: &mut Runtime<'_>) -> bool {
true
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
let pitch = WIDTH as usize * core::mem::size_of::<u16>();
let _ = runtime.video_refresh_frame_with_audio(
&self.frame,
WIDTH,
HEIGHT,
pitch,
&self.silence,
);
}
}
fn content_contract() -> ContentContract {
ContentContract::new("").with_support_no_game(true)
}
libretro::export_core!(HelloCore::default());
ContentContract::new("").with_support_no_game(true) describes a core that can
start without content. Required-content cores use extensions such as
"bin|dat" and return false from load_game when required content is absent
or invalid.
The software frame uses libretro’s default 0RGB1555 format. That keeps hello
world small, but a real software renderer may choose another pixel format
explicitly during load_game.
Audio batches are &[[i16; 2]]: one element is one stereo frame, left then
right. At 48,000 Hz and 60 FPS, this core submits 800 silent stereo frames per
video frame.
Build And Load
Build the core:
cargo build --release
On Linux, the output for the example is a dynamic library such as
target/release/libhello_libretro.so. Other platforms use their normal dynamic
library suffixes, such as .dylib or .dll.
Load the library in a libretro frontend without content. The expected result is a solid blue 320x240 frame and silence.
Running A Core
A libretro core is not a standalone executable. Cargo builds a dynamic library, then a frontend loads that library and drives the lifecycle.
For a workspace example:
cargo build --release -p hello-libretro
The output path is platform-specific. On Linux, the hello example builds to a file like:
target/release/libhello_libretro.so
Use the equivalent .dylib or .dll on other platforms.
The frontend is responsible for loading the core, choosing content or no
content, mapping input devices, opening audio/video devices, and calling the
core repeatedly. After load_game succeeds, every frontend call to run
should produce a video submission and enough audio to keep pacing stable.
When a core fails to load, prefer a diagnosable path:
- return
falsefromload_gamefor invalid required content, - use
runtime.logger()orruntime.set_message(...)for actionable failures, - keep hardware-render failures visible with diagnostic frames where possible.
The validation chapter for this workspace is Publishing and Validation.
Frame Loop Basics
Core::run is called once for each frontend frame. Keep it predictable:
- Poll input.
- Advance core state.
- Produce video.
- Produce audio.
- Submit the frame.
Software cores usually submit one framebuffer and one audio batch together:
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
self.framebuffer.fill(BLUE_0RGB1555);
let pitch = WIDTH as usize * core::mem::size_of::<u16>();
let accepted = runtime.video_refresh_frame_with_audio(
&self.framebuffer,
WIDTH,
HEIGHT,
pitch,
&self.silence,
);
if accepted != self.silence.len() {
let _ = runtime.set_message("audio batch was partially accepted", 120);
}
}
pitch is the number of bytes between the start of adjacent rows. For a tightly
packed u16 framebuffer, it is width * size_of::<u16>().
The combined helpers submit video first and audio second. Their return value is the number of stereo audio frames accepted by the frontend. Minimal examples can ignore that value; streaming audio cores should track short writes.
For software frame validation and detailed error handling, split the calls:
if runtime.video_refresh_frame(&self.framebuffer, WIDTH, HEIGHT, pitch) {
let _ = runtime.audio_sample_batch(&self.silence);
} else {
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, &self.silence);
}
After hardware rendering has been accepted, fallback paths should submit a hardware frame or duplicate frame with audio. Do not switch to software pixels inside an active hardware-render path.
Input
Libretro input is frontend-mapped. A core asks for abstract devices such as RetroPad, analog sticks, mouse, pointer, keyboard, and lightgun; the frontend maps physical hardware to those abstractions.
That means normal game code does not talk to a physical Xbox, PlayStation, keyboard, or arcade controller directly. It asks for the libretro abstraction that best describes the control scheme, and the frontend handles user mapping.
Most gameplay input is polled during run. Call runtime.poll_input() once per
frame before reading state:
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
if runtime.joypad_pressed(0, JoypadButton::A) {
self.jump();
}
let x = runtime.analog_axis(0, AnalogStick::Left, AnalogAxis::X);
}
Ports are player/controller slots. Port 0 is the first player.
Gamepads And RetroPad
Use RetroPad first when it can express the controls. It is the portable libretro gamepad abstraction and gives frontends the widest mapping freedom.
The RetroPad buttons are represented by JoypadButton: B, Y, Select,
Start, Up, Down, Left, Right, A, X, L, R, L2, R2, L3,
and R3.
For simple gameplay, ask one question at a time:
runtime.poll_input();
if runtime.joypad_pressed(0, JoypadButton::Start) {
self.paused = !self.paused;
}
if runtime.joypad_pressed(0, JoypadButton::A) {
self.jump();
}
For code that wants to scan several buttons at once, read the bitmask once and query it with typed buttons:
let buttons = runtime.joypad_buttons(0);
if buttons.contains(JoypadButton::Left) {
self.move_left();
}
if buttons.contains(JoypadButton::Right) {
self.move_right();
}
Analog sticks are separate from RetroPad button polling:
let move_x = runtime.analog_axis(0, AnalogStick::Left, AnalogAxis::X);
let move_y = runtime.analog_axis(0, AnalogStick::Left, AnalogAxis::Y);
The values are raw libretro-space signed axis values. Normalize or apply deadzones in your core only when that policy belongs to the game or emulator.
Polled Devices
- Joypad:
joypad_pressedfor individual buttons,joypad_buttonsfor a bitmask when scanning several buttons from the same port. - Analog:
analog_axisreturns signed libretro axis values;analog_buttonreturns analog button pressure. - Mouse:
mouse_axisreturns relative movement since the last poll. - Pointer:
pointer_axis,pointer_pressed,pointer_count, andpointer_is_offscreenrepresent absolute touch or pen-like input. - Lightgun: use
LightgunAxis::ScreenX,LightgunAxis::ScreenY,LightgunButton::Trigger,LightgunButton::Reload, andlightgun_is_offscreen.
Do not normalize raw input ranges in shared helpers unless the core owns that policy. Keeping libretro-space values visible makes calibration and frontend quirks easier to diagnose.
Descriptors And Controller Info
Input descriptors label controls for frontend UIs:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = env.set_input_descriptors(&[
InputDescriptor::joypad(0, JoypadButton::A, "Jump"),
InputDescriptor::analog(0, AnalogStick::Left, AnalogAxis::X, "Move"),
]);
}
Descriptors do not change input behavior; they make frontend menus and overlays show meaningful names such as “Jump” or “Move” instead of only generic button names.
Controller info declares which controller abstractions a port can use. It is
separate from runtime polling: declare selectable devices through
set_controller_info, then poll the matching base abstraction in run.
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = env.set_controller_info(&[
ControllerInfo::new(vec![
ControllerDescription::new("Gamepad", ControllerDevice::Joypad),
ControllerDescription::new("Analog", ControllerDevice::Analog),
]),
ControllerInfo::new(vec![
ControllerDescription::new("Gamepad", ControllerDevice::Joypad),
]),
]);
}
Each ControllerInfo entry describes one port. The example says player 1 can
choose Joypad or Analog, while player 2 only advertises Joypad.
Override set_controller_port_device when the core needs to react to frontend
controller selection.
Keyboard Events
Keyboard input is event-shaped in this Rust API. Register the listener next to the callback method:
impl Core for MyCore {
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events.add_keyboard_event_listener(Self::keyboard_event);
}
}
impl MyCore {
fn keyboard_event(&mut self, event: KeyboardEvent) {
if event.down {
let key = event.key;
let text = event.character.as_char();
}
}
}
You can register more than one listener for the same event. Listeners run in
registration order. Use the matching remove_*_listener method with the same
callback function when a configuration path needs to undo a registration.
Use KeyboardCharacter for layout-aware text input and KeyboardKey for
semantic special keys.
Reference: Input and Events.
Audio
Libretro audio is pushed by the core while the frontend is running it. During
each run call, most cores produce one video frame and the matching amount of
audio for the timing reported by av_info.
Samples are signed 16-bit stereo frames. In this crate, a batch is a slice of
[i16; 2], where each element is [left, right]. A value of 0 is silence.
Positive and negative values move the waveform around that center point.
The beginner path is:
- Pick video and audio timing in
av_info. - Keep an audio buffer in the core struct.
- Fill that buffer during
run. - Submit the video frame and audio batch together.
Silence First
The simplest fixed-rate path is 48,000 Hz at 60 FPS:
const FPS_HZ: u32 = 60;
const SAMPLE_RATE_HZ: u32 = 48_000;
struct MyCore {
silence: Vec<[i16; 2]>,
}
impl Default for MyCore {
fn default() -> Self {
Self {
silence: silent_stereo_frames_for_video_frame(SAMPLE_RATE_HZ, FPS_HZ),
}
}
}
That creates 800 stereo frames per video frame. Report the same timing to the frontend:
fn av_info(&self) -> SystemAvInfo {
fixed_system_av_info(WIDTH, HEIGHT, FPS_HZ as f64, SAMPLE_RATE_HZ as f64)
}
This is useful for early cores because it keeps the frontend audio device fed while you are still building rendering and input.
Submit Audio
Submit audio with video when possible:
let accepted = runtime.video_refresh_frame_with_audio(
&self.framebuffer,
WIDTH,
HEIGHT,
pitch,
&self.silence,
);
accepted is the number of stereo frames the frontend accepted. Simple silent
examples can ignore it. Streaming cores should watch for short acceptance and
adjust buffering or diagnostics.
If the core needs separate accounting, submit video first and then use
runtime.audio_sample_batch(&audio_frames). The combined helpers are easier to
read for the common case because they make it obvious that every rendered frame
also submits audio.
Generate Samples
Generated audio usually belongs in core state. Store phase, oscillators,
resamplers, or decoded audio queues in your core struct, then write the next
batch during run:
fn fill_square_wave(&mut self) {
self.audio.clear();
for _ in 0..self.samples_per_frame {
let sample = if self.phase < 0.5 { 8_000 } else { -8_000 };
self.audio.push([sample, sample]);
self.phase += 440.0 / SAMPLE_RATE_HZ as f32;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
}
}
Real emulators normally produce audio from the emulated audio hardware rather
than a simple waveform, but the shape is the same: keep audio state in the core,
fill a [i16; 2] batch, and submit it during run.
Pacing
For exact fixed timing such as 48,000 Hz at 60 FPS, every video frame gets the
same number of audio frames. For timing where sample_rate / fps is not an
integer, such as 48,000 Hz at 59.94 FPS, do not use
silent_stereo_frames_for_video_frame; it intentionally requires exact integer
division.
Use an accumulator that alternates batch sizes over time so the long-term sample
count matches the reported SystemTiming. Both the accumulator and the
reported SystemTiming::fps must use the same fractional FPS value:
const SAMPLE_RATE_HZ: u32 = 48_000;
const VIDEO_FPS: f64 = 59.94;
fn audio_frames_for_next_video_frame(&mut self) -> usize {
self.audio_remainder += SAMPLE_RATE_HZ as f64;
let frames = (self.audio_remainder / VIDEO_FPS).floor() as usize;
self.audio_remainder -= frames as f64 * VIDEO_FPS;
frames
}
The exact helper shape depends on how the core represents timing, but the rule
is always the same: do not slowly drift away from the SystemAvInfo values you
reported to the frontend.
Frame Time Callback
Frame time is a frontend notification that reports elapsed microseconds between
frames. It is useful when a core can use frontend pacing data instead of
assuming every run call is exactly one nominal video frame.
Register it with a single callback:
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events.set_frame_time_callback(FrameTime::from_micros(16_667), Self::frame_time);
}
fn frame_time(&mut self, elapsed: FrameTime) {
self.last_frame_time = elapsed;
}
The reference value passed to set_frame_time_callback is the core’s expected
frame interval. For a 60 FPS core, 16_667 microseconds is the usual reference.
The frontend stores one frame-time callback table, so this API uses set/clear
wording instead of add/remove listener wording. Calling
set_frame_time_callback again replaces the callback and reference.
Audio Callbacks
Audio callback events are separate from normal pushed audio. The normal path is
to submit samples during run. Frontend-driven audio callback mode should be
registered through configure_events only when the core is designed for that
scheduling model. The request callback receives no extra event argument; the
state callback receives AudioCallbackState:
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events
.add_audio_callback_listener(Self::audio_callback)
.add_audio_callback_state_changed_listener(Self::audio_callback_state_changed);
}
fn audio_callback(&mut self) {
// Produce audio for callback-driven scheduling.
}
fn audio_callback_state_changed(&mut self, state: AudioCallbackState) {
self.audio_callback_active = state.is_active();
}
Audio buffer status is a separate frontend notification about output buffering.
It receives AudioBufferStatus, including whether callback audio is active, the
reported occupancy, and whether an underrun is likely:
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events.add_audio_buffer_status_listener(Self::audio_buffer_status);
}
fn audio_buffer_status(&mut self, status: AudioBufferStatus) {
if status.underrun_likely {
// Adjust buffering or record a diagnostic.
}
}
Reference: Runtime Video and Audio.
OpenGL
Libretro hardware rendering is a frontend-owned OpenGL or OpenGL ES context. The core negotiates a context, initializes GL state when the frontend creates the context, renders into the frontend-provided framebuffer each frame, and deletes GL-owned objects before the context is gone.
The important difference from SDL, GLFW, or a standalone OpenGL app is that the core does not create the window or the GL context. The frontend does. The core asks for a context, receives callbacks when it is available, and looks up GL symbols through libretro.
libretro-core exposes that as one typed handle: Gl.
Gl::init(runtime)is called after the context reset callback.- The clear/framebuffer path is mandatory so a core can always try to show a legal hardware frame.
- Shader, buffer, texture, blending, vertex-array, and other richer symbols are optional. Methods that create resources or draw return errors when their GL feature is unavailable. State cleanup methods no-op when the feature is not loaded.
This lets a core start with a visible clear-only diagnostic, then layer in a triangle, textures, text, or a full renderer as symbols are available.
Core State
Store GL-owned handles on your core and clear them when the context is destroyed. Handles are only valid for the current frontend-owned context.
struct MyCore {
gl: Option<Gl>,
program: Option<GlProgram>,
vbo: Option<GlBuffer>,
}
Negotiate
Request hardware rendering from load_game:
fn load_game(&mut self, _game: Option<GameInfo<'_>>, runtime: &mut Runtime<'_>) -> bool {
let mut env = runtime.environment();
if !env.set_pixel_format(PixelFormat::Xrgb8888) {
return false;
}
let candidates = opengl_modern_preferred_hw_render_candidates();
env.set_hw_render_from_candidates(&candidates).is_some()
}
Use opengl_modern_preferred_hw_render_candidates() for the modern demo path.
Use opengl_compatibility_hw_render_candidates() when targeting the smaller
compatibility/diagnostic path.
If negotiation fails, software video is still allowed because hardware mode was not accepted yet. After hardware mode is accepted, submit hardware or duplicate frames only.
Reset
Load symbols and rebuild context-owned objects in hw_context_reset:
fn hw_context_reset(&mut self, runtime: &mut Runtime<'_>) {
let gl = Gl::init(runtime)
.unwrap_or_else(|error| panic!("failed to load OpenGL symbols: {error}"));
let program = gl
.build_program(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE)
.unwrap_or_else(|error| panic!("failed to build GL program: {error}"));
self.gl = Some(gl);
self.program = Some(program);
}
Gl::init loads the legal clear/framebuffer path first and treats richer
shader, buffer, texture, and blend symbols as optional. Methods that only
restore state or clean up unavailable features are no-ops; methods that need
missing rendering resources return a diagnosable error so cores can fall back.
The frontend may recreate the context. Treat the symbol table, programs, buffers, textures, vertex arrays, and framebuffer-dependent state as context-lifetime state.
Clear First
A clear-only frame is the smallest useful hardware-rendered output. It is also the best first diagnostic because it proves that negotiation, framebuffer lookup, binding, viewport setup, and frame submission all work.
fn draw_clear_frame(&self, runtime: &mut Runtime<'_>, gl: &Gl, audio: &[[i16; 2]]) {
let Some(framebuffer) = runtime.current_framebuffer() else {
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, audio);
return;
};
if gl
.bind_framebuffer(
GlFramebufferTarget::Framebuffer,
GlFramebuffer::from_raw(framebuffer),
)
.is_err()
{
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, audio);
return;
}
let _ = gl.viewport(GlRect::new(0, 0, WIDTH, HEIGHT));
gl.clear_color(0.08, 0.09, 0.12, 1.0);
gl.clear_color_buffer();
gl.unbind_framebuffer(GlFramebufferTarget::Framebuffer);
let _ = runtime.video_refresh_hw_with_audio(WIDTH, HEIGHT, 0, audio);
}
Set the viewport every frame. The frontend may reuse the context for its own
work, and current_framebuffer() can return a different FBO on different
frames.
Build A Program And Buffer
Once the clear path works, build renderer resources in hw_context_reset.
Program creation, buffer creation, and buffer upload can fail if the frontend
does not expose the required symbols. Keep the error visible instead of silently
continuing to a black frame.
fn hw_context_reset(&mut self, runtime: &mut Runtime<'_>) {
self.destroy_gl_state();
let gl = Gl::init(runtime)
.unwrap_or_else(|error| panic!("failed to load OpenGL symbols: {error}"));
let (vertex_source, fragment_source) = shader_sources_for(gl.context_type());
let program = gl
.build_program(vertex_source, fragment_source)
.unwrap_or_else(|error| panic!("failed to build GL program: {error}"));
let vertices: [f32; 15] = [
0.0, 0.6, 1.0, 0.0, 0.0,
-0.6, -0.6, 0.0, 1.0, 0.0,
0.6, -0.6, 0.0, 0.0, 1.0,
];
let vbo = gl
.gen_buffer()
.unwrap_or_else(|error| panic!("failed to create GL buffer: {error}"));
gl.bind_buffer(GlBufferTarget::ArrayBuffer, Some(vbo));
gl.buffer_data(
GlBufferTarget::ArrayBuffer,
&vertices,
GlBufferUsage::StaticDraw,
)
.unwrap_or_else(|error| panic!("failed to upload GL buffer: {error}"));
gl.unbind_buffer(GlBufferTarget::ArrayBuffer);
self.program = Some(program);
self.vbo = Some(vbo);
self.gl = Some(gl);
}
Use shader sources that match the negotiated context family. For example, a
core-profile desktop context needs modern in/out syntax, while GLES2 and
desktop compatibility contexts can use attribute/varying style shaders.
Render
Ask for the current framebuffer every frame. It can change between frames.
let Some(framebuffer) = runtime.current_framebuffer() else {
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, &self.audio);
return;
};
let Some(gl) = self.gl.as_ref() else {
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, &self.audio);
return;
};
if gl
.bind_framebuffer(
GlFramebufferTarget::Framebuffer,
GlFramebuffer::from_raw(framebuffer),
)
.is_err()
{
let _ = runtime.video_refresh_dupe_with_audio(WIDTH, HEIGHT, &self.audio);
return;
}
let _ = gl.viewport(GlRect::new(0, 0, WIDTH, HEIGHT));
gl.clear_color(0.08, 0.09, 0.12, 1.0);
gl.clear_color_buffer();
gl.unbind_framebuffer(GlFramebufferTarget::Framebuffer);
let _ = runtime.video_refresh_hw_with_audio(WIDTH, HEIGHT, 0, &self.audio);
For a triangle draw, bind the program and buffer, configure typed vertex attributes, draw, then restore the shared state you touched:
let Some(program) = self.program else {
return Ok(());
};
let Some(vbo) = self.vbo else {
return Ok(());
};
gl.use_program(Some(program));
gl.bind_buffer(GlBufferTarget::ArrayBuffer, Some(vbo));
let position = gl.required_attrib_location(program, "a_pos")?;
let color = gl.required_attrib_location(program, "a_color")?;
gl.enable_vertex_attrib(position);
gl.vertex_attrib_pointer_f32(
position,
GlVertexAttribF32Layout::interleaved(GlVertexAttribF32Components::Two, 5)?,
);
gl.enable_vertex_attrib(color);
gl.vertex_attrib_pointer_f32(
color,
GlVertexAttribF32Layout::interleaved(GlVertexAttribF32Components::Three, 5)?
.with_offset_components(GlVertexAttribF32Components::Two),
);
gl.draw_arrays(GlDrawMode::Triangles, GlDrawRange::from_start(3))?;
gl.disable_vertex_attrib(position);
gl.disable_vertex_attrib(color);
gl.unbind_buffer(GlBufferTarget::ArrayBuffer);
gl.use_no_program();
Do not leave shared GL state such as framebuffers, buffers, textures, vertex arrays, enabled attributes, or programs bound when handing the frame back to the frontend.
After hardware rendering is active, fallback frames should be hardware or duplicate submissions with audio. Software-pixel fallback is only appropriate before hardware negotiation succeeds.
Upload A Texture
Texture helpers use typed targets, formats, filters, wraps, levels, and sizes. If texture symbols are not available, resource creation and upload methods return an error and cleanup calls no-op.
let texture = gl.gen_texture()?;
gl.active_texture(GlTextureUnit::ZERO)?;
gl.bind_texture(GlTextureTarget::Texture2D, Some(texture));
gl.pixel_store_unpack_alignment(GlPixelStoreAlignment::One);
gl.tex_min_filter(GlTextureTarget::Texture2D, GlTextureMinFilter::Nearest);
gl.tex_mag_filter(GlTextureTarget::Texture2D, GlTextureMagFilter::Nearest);
gl.tex_wrap_s(GlTextureTarget::Texture2D, GlTextureWrap::ClampToEdge);
gl.tex_wrap_t(GlTextureTarget::Texture2D, GlTextureWrap::ClampToEdge);
gl.tex_image_2d(
GlTextureTarget::Texture2D,
GlTextureInternalFormat::Rgba,
GlTextureLevel::ZERO,
GlTextureSize2D::new(width, height),
GlTextureFormat::Rgba,
GlTextureDataType::UnsignedByte,
Some(rgba_bytes),
)?;
gl.unbind_texture(GlTextureTarget::Texture2D);
gl.pixel_store_unpack_alignment(GlPixelStoreAlignment::Four);
When drawing textured geometry, set the sampler uniform with a typed uniform location:
let font_location = gl.required_uniform_location(program, "u_font")?;
gl.uniform_1i(font_location, 0);
Optional Features
Use capability helpers when a fallback path needs to choose between features:
if gl.supports_shader_pipeline() {
// Build a shader/buffer renderer.
} else {
// Keep a clear-only diagnostic frame visible.
}
if gl.supports_textures() {
// Add bitmap text or sprites.
}
if gl.supports_vertex_arrays() {
// Use VAOs for core-profile desktop GL.
}
The compatibility diagnostic example follows this shape: clear first, then triangle, then text. Each layer can fail without hiding the earlier visible output.
Destroy
Delete GL-owned objects in hw_context_destroy or from unload_game while a
valid context is still available:
fn hw_context_destroy(&mut self, _runtime: &mut Runtime<'_>) {
if let Some(gl) = self.gl.as_ref() {
if let Some(program) = self.program.take() {
gl.delete_program(program);
}
}
self.gl = None;
}
It is fine to call cleanup methods even when an optional feature is unavailable.
For example, delete_texture, unbind_texture, disable, and use_no_program
are no-ops if that symbol group was not loaded.
Troubleshooting
Use visible diagnostics for frontend compatibility work. The
retrocompat-libretro example uses staged GL initialization, clear-color
failure states, frontend messages, text overlays, and legal duplicate-frame
fallbacks after hardware mode is accepted.
Useful debugging sequence:
- Clear the frontend FBO and submit a hardware frame.
- Add shader and buffer setup for one triangle.
- Add texture upload only after the triangle path is stable.
- Add text or richer renderer state last.
- On failure, show a frontend message and keep the last simpler visible path.
Reference: Hardware Rendering and OpenGL.
Core Lifecycle
Core authors implement the Core trait. The crate owns the raw retro_*
exports through export_core!, catches panics at ABI boundaries, and converts
frontend callbacks into typed Runtime and Environment values.
Core Trait
Core is a trait, not a struct supplied by the library. You create the struct
that stores your core’s state, then implement Core for it.
struct MyCore {
frame: Vec<u16>,
content_loaded: bool,
}
impl Core for MyCore {
fn system_info(&self) -> SystemInfo {
SystemInfo::new("my-core", "0.1.0")
}
fn load_game(&mut self, _game: Option<GameInfo<'_>>, _runtime: &mut Runtime<'_>) -> bool {
self.content_loaded = true;
true
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
}
}
The struct is yours. The trait methods are the points where the libretro frontend asks your code for metadata, content decisions, frame execution, and cleanup.
export_core! connects your Rust type to libretro:
libretro::export_core!(MyCore::default());
After that, frontends call the generated retro_* symbols and the library
dispatches those calls to your Core implementation.
The usual lifecycle is:
system_inforeturns a stableSystemInfo.on_set_environmentregisters content support, options, callbacks, and frontend capabilities.load_gameaccepts or rejects content and performs runtime setup.av_inforeturns fixed or dynamic geometry/timing.runpolls input, advances emulation, and submits video/audio.unload_gameanddeinitrelease state owned by the core.
Hardware cores also implement hw_context_reset and hw_context_destroy so GL
objects are tied to the frontend-owned context lifetime.
The high-level pieces are:
Core: the trait core authors implement.Runtime: per-frame access to input, video, audio, memory, and frontend services.Environment: setup-time and runtime environment commands.export_core!: exports the libretro ABI symbols.
Core State Vs Runtime
Keep persistent data in your core struct:
- loaded ROM or content metadata,
- emulation/game state,
- framebuffers and audio scratch buffers,
- user-visible diagnostics,
- GL objects created during
hw_context_reset, - counters, timers, and cached frontend decisions.
Use Runtime only while a callback is running. It is borrowed from the library
and should not be stored in your struct. If a frontend capability or handle must
be reused later, copy the typed value you need into your own state.
| Frontend step | Core method | Main handle |
|---|---|---|
| Metadata query | system_info | SystemInfo |
| Environment setup | on_set_environment | Environment |
| Content load | load_game | Runtime |
| AV query | av_info | SystemAvInfo |
| Frame execution | run | Runtime |
| Content unload | unload_game | core state |
| Core shutdown | deinit | core state |
Keep user code on the typed side of the boundary. Raw ABI details are available
for auditing and tests, but normal cores should not need unsafe blocks or raw
RETRO_ENVIRONMENT_* command numbers.
Tutorials:
Content, AV, and Timing
Three things have to agree before a frontend will accept frames from a core:
the content contract (which file extensions and no-game modes are
supported), the load result (whether load_game accepted the supplied
content), and the AV info (geometry, FPS, and sample rate). This chapter
covers those three surfaces and the helpers that keep them consistent.
Content Contract
ContentContract is a builder that captures everything the frontend needs to
know about content support up front:
fn content_contract() -> ContentContract {
ContentContract::new("bin|dat")
.with_support_no_game(true)
.with_persistent_data(true)
.with_need_fullpath(false)
.with_block_extract(false)
}
The same builder is used in two places, so the frontend sees one consistent
view. From system_info, apply it to the metadata:
fn system_info(&self) -> SystemInfo {
let mut info = SystemInfo::new("my-core", env!("CARGO_PKG_VERSION"));
content_contract().apply_to_system_info(&mut info);
info
}
From on_set_environment, register the runtime side:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = content_contract().register_environment(env);
}
apply_to_system_info writes valid_extensions, need_fullpath, and
block_extract. register_environment calls set_support_no_game and
installs the content-info overrides. Keep the helper free of side effects so
it can be called from both lifecycle points.
The four flags are independent:
| Flag | Meaning |
|---|---|
with_support_no_game(true) | Core can run without content (load_game(None, ...) returns true). |
with_need_fullpath(true) | Frontend supplies a filesystem path instead of a borrowed byte slice. Useful for emulators that mmap. |
with_block_extract(true) | Frontend should not auto-extract archives before handing content to the core. |
with_persistent_data(true) | Core promises the borrowed data slice survives past load_game. Required when reusing the slice from run. |
GameInfo
load_game receives Option<GameInfo<'_>>. None means no-game startup
(only valid if you set with_support_no_game(true)). Some(info) borrows
three optional fields from the frontend:
fn load_game(&mut self, game: Option<GameInfo<'_>>, runtime: &mut Runtime<'_>) -> bool {
let Some(game) = game else {
return true; // no-game start
};
if let Some(path) = game.path_lossy() {
runtime.logger().info(format!("loading {path}"));
}
let bytes = game.data.unwrap_or(&[]);
self.load_rom(bytes)
}
The fields are path: Option<&CStr>, data: Option<&[u8]>, and
meta: Option<&CStr>. Use path_lossy() and meta_lossy() when you want
Cow<'_, str> instead of raw CStr. The borrow ends when load_game
returns unless with_persistent_data(true) is set.
Return false from load_game to refuse the content. The frontend then
shows the failure to the user instead of running a broken core. Use
runtime.logger() or runtime.set_message(...) so the failure is
diagnosable.
SystemAvInfo
av_info reports geometry and timing in one value:
fn av_info(&self) -> SystemAvInfo {
fixed_system_av_info(320, 240, 60.0, 48_000.0)
}
SystemAvInfo decomposes into GameGeometry and SystemTiming:
| Field | Type | Notes |
|---|---|---|
geometry.base_width / base_height | u32 | The current visible frame size. |
geometry.max_width / max_height | u32 | The largest frame the core will ever submit. Frontend may allocate for this size. |
geometry.aspect_ratio | f32 | Use 0.0 to let the frontend derive aspect from base size. |
timing.fps | f64 | Target frame rate. |
timing.sample_rate | f64 | Audio sample rate in Hz. |
Three builders cover the common shapes:
let g = game_geometry(320, 240); // base == max
let g = bounded_game_geometry(320, 240, 640, 480); // distinct base / max
let av = system_av_info(g, 60.0, 48_000.0);
let av = fixed_system_av_info(320, 240, 60.0, 48_000.0); // shorthand for both
For audio pacing helpers, see Audio. The most useful are
silent_stereo_frames_for_video_frame(sample_rate, fps) (panics on
non-integer division) and exact_audio_frames_per_video_frame(sample_rate, fps).
Dynamic Geometry and Timing
If the visible image resizes during gameplay (a console switching between
240p and 480i, or a windowed app changing internal resolution), update the
frontend through Runtime::set_geometry:
fn run(&mut self, runtime: &mut Runtime<'_>) {
if self.resolution_changed {
runtime.set_geometry(game_geometry(self.width, self.height));
self.resolution_changed = false;
}
// ... submit frame
}
set_geometry only adjusts the geometry block; the frontend keeps its
existing timing. Use Environment::set_system_av_info when both geometry
and timing change at runtime — it replaces the entire SystemAvInfo and
gives the frontend a chance to resynchronize audio and video.
let mut env = runtime.environment();
let _ = env.set_system_av_info(fixed_system_av_info(640, 480, 59.94, 48_000.0));
Reporting max_width/max_height larger than any frame you actually submit
is fine and avoids reallocation when geometry changes inside those bounds.
Reference
- Software example — minimal contract + fixed AV info.
- Modern OpenGL example — content optional, HW
rendering negotiated after
load_game. - Developing Libretro Cores
- Dynamic Rate Control
Runtime Video and Audio
Runtime is the per-frame handle passed to Core::run. It provides typed
helpers for polling input, submitting software frames, submitting hardware
frames, sending audio, showing frontend messages, and querying services.
It is also passed to a few lifecycle methods, such as load_game and
hw_context_reset, when those methods need frontend access. Do not store
Runtime in your core struct. Store your own state, and use the Runtime
borrow only during the current callback.
What Runtime Offers
Runtime groups the frontend operations that a running core commonly needs:
| Job | Examples |
|---|---|
| Input | poll_input, joypad_pressed, joypad_buttons, analog_axis, mouse_axis, pointer_pressed |
| Video | video_refresh_frame_with_audio, video_refresh_hw_with_audio, video_refresh_dupe_with_audio |
| Audio | audio_sample, audio_sample_batch, combined video/audio helpers |
| Hardware rendering | current_framebuffer, hw_proc_address through Gl::init |
| Messages and logs | set_message, logger |
| Environment | environment() for runtime-valid environment commands |
| Frontend services | rumble, LED, sensors, camera, microphone, MIDI, VFS, performance, netplay helpers |
Runtime is not the core state, a scheduler, or a long-lived service object.
The frontend creates the callback context, the library lends it to your core,
and the borrow ends when the callback returns. Keep emulator state, frame
buffers, audio queues, GL object handles, and user settings on your core struct.
Use Runtime to communicate with the frontend during the current callback.
The most common frame shape is:
fn run(&mut self, runtime: &mut Runtime<'_>) {
runtime.poll_input();
self.update_from_input(runtime);
self.advance_one_frame();
self.submit_frame(runtime);
}
Input From Runtime
Calling poll_input tells the frontend to refresh its input state for this
frame. After that, typed input helpers read the updated state:
runtime.poll_input();
if runtime.joypad_pressed(0, JoypadButton::A) {
self.jump();
}
let x = runtime.analog_axis(0, AnalogStick::Left, AnalogAxis::X);
Ports are player slots. Port 0 is player 1, port 1 is player 2, and so on.
Joypad helpers read the RetroPad abstraction. Analog, mouse, pointer, and
lightgun helpers expose libretro-space raw values so the core can choose its own
normalization and deadzone policy.
For more detail, see Input and Input and Events.
Video From Runtime
Software cores usually submit one frame and one audio batch together:
runtime.poll_input();
let pitch = width as usize * core::mem::size_of::<u16>();
let _ = runtime.video_refresh_frame_with_audio(
&framebuffer,
width,
height,
pitch,
&audio_frames,
);
The software frame slice, dimensions, and pitch must describe the same image.
Keep the framebuffer in your core state so run can update it without
allocating every frame.
Hardware Rendering From Runtime
Hardware cores render into the frontend-provided framebuffer and then submit a hardware frame:
let Some(framebuffer) = runtime.current_framebuffer() else {
let _ = runtime.video_refresh_dupe_with_audio(width, height, &audio_frames);
return;
};
gl.bind_framebuffer(GlFramebufferTarget::Framebuffer, GlFramebuffer::from_raw(framebuffer))?;
runtime.video_refresh_hw_with_audio(width, height, 0, &audio_frames);
current_framebuffer is valid only while the frontend has an active hardware
context for the current frame. OpenGL function loading is also runtime-backed:
initialize the typed Gl facade from the runtime context reset path, then keep
the resulting facade on your core while the context is alive.
For more detail, see OpenGL and Hardware Rendering and OpenGL.
Audio From Runtime
Prefer the combined video/audio helpers where possible. They make frame pacing visible in the call site and reduce the chance that a frame returns without submitting audio.
Helper choices:
| Situation | Helper |
|---|---|
| Software pixels plus audio | video_refresh_frame_with_audio |
| Hardware-rendered frame plus audio | video_refresh_hw_with_audio |
| Duplicate previous frame plus audio | video_refresh_dupe_with_audio |
| Separate accounting | video_refresh_* plus audio_sample_batch |
The *_with_audio helpers return the number of stereo audio frames accepted by
the frontend. They are convenient for the common path; split video and audio
calls when the core needs to handle software-frame validation and audio
acceptance independently.
Audio batches are slices of [i16; 2], where each element is one signed 16-bit
stereo frame. Keep generated samples, silence buffers, decoded queues, and
resampling state on your core. Use Runtime only to submit the samples.
For more detail, see Audio.
Environment From Runtime
Some environment commands are needed after setup. Use runtime.environment() to
get a typed Environment view:
let mut env = runtime.environment();
let _ = env.set_message("Paused", 120);
For simple frontend messages, Runtime also exposes convenience helpers:
let _ = runtime.set_message("Loading failed", 180);
runtime.logger().warn("using fallback renderer");
Frontend Services
Some frontend-owned services are discovered or driven through runtime-valid environment calls: rumble, LED, sensors, camera, microphone, MIDI, VFS, performance counters, and netplay helpers. Treat them as optional frontend capabilities. Query or negotiate them through the typed API, keep any core-side state in your core struct, and continue to produce diagnosable frames when a service is unavailable.
For more detail, see Frontend Services.
After hardware rendering has been accepted, submit hardware or duplicate frames from fallback paths. Do not submit software pixels from an active hardware path.
Tutorials:
Input and Events
Libretro has two input shapes, and the Rust API keeps them distinct.
Polled input is requested by the core during run. Call runtime.poll_input()
once per frame, then use typed helpers:
runtime.poll_input();
if runtime.joypad_pressed(0, JoypadButton::A) {
// advance game state
}
let x = runtime.analog_axis(0, AnalogStick::Left, AnalogAxis::X);
Ports are player/controller slots. Port 0 is the first player. Prefer
joypad_pressed for beginner snippets; use joypad_buttons when scanning
several RetroPad buttons from the same port.
Gamepad Support
RetroPad is the default gamepad abstraction. Frontends map physical controllers
to JoypadButton values, so core code can stay portable:
if runtime.joypad_pressed(0, JoypadButton::B) {
self.fire();
}
let buttons = runtime.joypad_buttons(0);
let mut horizontal = 0;
if buttons.contains(JoypadButton::Right) {
horizontal += 1;
}
if buttons.contains(JoypadButton::Left) {
horizontal -= 1;
}
Use Environment::input_device_capabilities during environment negotiation when
the core wants to tailor setup to devices the frontend reports. Keep the normal
runtime path typed and simple: poll once, then read joypad, analog, mouse,
pointer, or lightgun state.
Mouse axes are relative deltas. Pointer and modern lightgun axes are absolute screen-space values. Analog, pointer, and lightgun helpers return libretro-space raw values, so normalize them only when the core owns the policy.
Use Environment::set_input_descriptors to label controls and
Environment::set_controller_info to advertise selectable controller types.
Override set_controller_port_device when a core needs to react to frontend
controller selection.
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = env.set_input_descriptors(&[
InputDescriptor::joypad(0, JoypadButton::B, "Fire"),
InputDescriptor::joypad(0, JoypadButton::A, "Jump"),
]);
let _ = env.set_controller_info(&[
ControllerInfo::new(vec![
ControllerDescription::new("Gamepad", ControllerDevice::Joypad),
]),
]);
}
Descriptors are labels. Controller info advertises selectable controller
abstractions. The actual per-frame state still comes from Runtime polling.
Event Callbacks
Event callbacks are frontend-to-core notifications. Register them in
configure_events using DOM-style listener methods. The library then installs
the raw frontend callback during environment setup; normal core code does not
call a separate raw callback setup method.
impl Core for MyCore {
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events.add_keyboard_event_listener(Self::keyboard_event);
}
}
impl MyCore {
fn keyboard_event(&mut self, event: KeyboardEvent) {
if event.down {
let text = event.character.as_char();
let key = event.key;
}
}
}
The listener API follows DOM-style add/remove semantics. You can add multiple
listeners for the same event, duplicate callback registrations are ignored, and
listeners run in registration order. Call the matching remove_*_listener
method with the same callback function to remove a listener during
configuration. Callback-shaped hooks that have one active frontend registration
keep set/clear wording; for example, frame timing uses
set_frame_time_callback(reference, callback) and clear_frame_time_callback().
fn configure_events(&mut self, events: &mut CoreEventConfig<Self>) {
events
.add_keyboard_event_listener(Self::keyboard_event)
.remove_keyboard_event_listener(Self::keyboard_event);
}
Use KeyboardCharacter for layout-aware text input. Use KeyboardKey for
semantic special keys, and provide configurable bindings when physical keyboard
layout matters.
Other event-shaped surfaces include audio callbacks, audio buffer status, location lifecycle, and camera lifecycle/frame notifications. Frame timing is a single callback-shaped hook because the frontend receives one reference interval. Joypad, analog, mouse, pointer, and lightgun input remain polled because libretro exposes them that way.
Event Callback Reference
Register event callbacks from Core::configure_events. Every callback receives
&mut self as its first argument through the method pointer you pass to
CoreEventConfig; the table below shows the additional event payload, if any.
| Frontend notification | Register | Remove or clear | Callback method shape |
|---|---|---|---|
| Keyboard key/text event | add_keyboard_event_listener(Self::keyboard_event) | remove_keyboard_event_listener(Self::keyboard_event) | fn keyboard_event(&mut self, event: KeyboardEvent) |
| Audio callback request | add_audio_callback_listener(Self::audio_callback) | remove_audio_callback_listener(Self::audio_callback) | fn audio_callback(&mut self) |
| Audio callback active state changed | add_audio_callback_state_changed_listener(Self::audio_callback_state_changed) | remove_audio_callback_state_changed_listener(Self::audio_callback_state_changed) | fn audio_callback_state_changed(&mut self, state: AudioCallbackState) |
| Audio buffer status changed | add_audio_buffer_status_listener(Self::audio_buffer_status) | remove_audio_buffer_status_listener(Self::audio_buffer_status) | fn audio_buffer_status(&mut self, status: AudioBufferStatus) |
| Frame time reported | set_frame_time_callback(reference, Self::frame_time) | clear_frame_time_callback() | fn frame_time(&mut self, elapsed: FrameTime) |
| Location initialized | add_location_initialized_listener(Self::location_initialized) | remove_location_initialized_listener(Self::location_initialized) | fn location_initialized(&mut self) |
| Location deinitialized | add_location_deinitialized_listener(Self::location_deinitialized) | remove_location_deinitialized_listener(Self::location_deinitialized) | fn location_deinitialized(&mut self) |
| Camera initialized | add_camera_initialized_listener(Self::camera_initialized) | remove_camera_initialized_listener(Self::camera_initialized) | fn camera_initialized(&mut self) |
| Camera deinitialized | add_camera_deinitialized_listener(Self::camera_deinitialized) | remove_camera_deinitialized_listener(Self::camera_deinitialized) | fn camera_deinitialized(&mut self) |
| Camera raw frame | add_camera_raw_frame_listener(Self::camera_raw_frame) | remove_camera_raw_frame_listener(Self::camera_raw_frame) | fn camera_raw_frame(&mut self, frame: CameraRawFrame<'_>) |
| Camera texture frame | add_camera_texture_frame_listener(Self::camera_texture_frame) | remove_camera_texture_frame_listener(Self::camera_texture_frame) | fn camera_texture_frame(&mut self, frame: CameraTextureFrame) |
Payload types keep libretro units but hide raw callback table details:
KeyboardEventhasdown,key,character, andmodifiers. UseKeyboardCharacter::as_char()when you need layout-aware text.AudioCallbackStateisActiveorInactive. The request callback has no extra payload; it means the frontend is asking the core to produce audio for callback-driven audio mode.AudioBufferStatushasactive,occupancy, andunderrun_likely.occupancy.percent()returnsSome(0..=100)for normal frontend values;raw_percent()preserves out-of-range frontend data for diagnostics.FrameTimestores signed microseconds. UseFrameTime::as_micros()when you need the numeric elapsed time.- Location lifecycle callbacks have no extra payload. They tell the core when the frontend-owned location interface has initialized or deinitialized.
- Camera lifecycle callbacks have no extra payload. Camera frame callbacks carry
either
CameraRawFrame<'_>withpixels,width,height, andpitch_bytes, orCameraTextureFramewithtexture_id,texture_target, and a 3x3affinetransform.
Tutorial: Input.
Reference: Libretro Input API.
Environment and Core Options
Environment is the typed wrapper around libretro’s RETRO_ENVIRONMENT_*
commands. A core uses it for two distinct jobs:
- Setup negotiation during
on_set_environmentandload_game: declare content support, pixel format, input descriptors, controller info, core options, and hardware rendering. - Runtime queries and updates through
runtime.environment(): read language, paths, throttle state, current options, and push messages or geometry changes.
Most environment calls return bool (or Option<T>) so a core can tell
unsupported vs rejected vs successful. Treat a false return as “the
frontend declined this specific request”, not as a panic condition.
Setup Negotiation
on_set_environment is the canonical setup hook. The frontend calls it
once, before content load, when the environment callback is first wired up.
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let _ = ContentContract::new("bin|dat")
.with_support_no_game(true)
.register_environment(env);
let _ = env.set_input_descriptors(&[
InputDescriptor::joypad(0, JoypadButton::A, "Jump"),
InputDescriptor::joypad(0, JoypadButton::B, "Fire"),
]);
let _ = env.set_controller_info(&[
ControllerInfo::new(vec![
ControllerDescription::new("Gamepad", ControllerDevice::Joypad),
]),
]);
}
A few more setup-time commands you usually want:
let _ = env.set_pixel_format(PixelFormat::Xrgb8888);
let _ = env.set_performance_level(PerformanceLevel::new(2));
let _ = env.set_support_no_game(true);
For hardware rendering, see Hardware Rendering and OpenGL. For content, see Content, AV, and Timing.
Core Options
Two installation paths exist because libretro evolved the options API. The crate exposes both and lets the core install whichever version the frontend supports.
The modern path is set_core_options_v2, which accepts a CoreOptions
value built from typed definitions and optional categories:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
if env.core_options_version().supports_v2() {
let options = CoreOptions::new([
CoreOptionDefinition::new("my_core_mode", "Mode", "auto")
.with_values([
CoreOptionValue::new("auto").with_label("Auto-detect"),
CoreOptionValue::new("fast").with_label("Fast"),
CoreOptionValue::new("accurate").with_label("Accurate"),
])
.with_info("Picks between speed and accuracy.")
.with_category("performance"),
])
.with_categories([
CoreOptionCategory::new("performance", "Performance"),
]);
let _ = env.set_core_options_v2(options);
} else {
let _ = env.set_variables(&[
VariableDefinition::new(
"my_core_mode",
"Mode; auto|fast|accurate",
),
]);
}
}
core_options_version() returns a value with supports_v1() and
supports_v2() flags. Use the v2 path on modern frontends and fall back to
set_variables (legacy VariableDefinition) only when needed — the v2
storage retains all strings so the frontend can read descriptors safely.
When option visibility should change at runtime, use
set_core_option_display(CoreOptionDisplay::new("my_core_mode", false)) to
hide an option and set_variable(key, value) to push a value back to the
frontend.
Reading Option Values
During run, read current values from the frontend through
Environment::get_variable:
fn run(&mut self, runtime: &mut Runtime<'_>) {
let mut env = runtime.environment();
if env.variables_updated() {
if let Some(mode) = env.get_variable("my_core_mode") {
self.apply_mode(&mode);
}
}
// ... advance frame
}
variables_updated() returns true once after the frontend changes any
option, so you can avoid re-applying the same value every frame.
Messages and Logging
Two message surfaces exist. The simple one shows a string for a number of frames:
let _ = runtime.set_message("Loading failed", 180);
runtime.logger().error("renderer init failed");
The richer one uses ExtendedMessage with target, kind, level, and
progress:
let mut env = runtime.environment();
if env.message_interface_version().is_some() {
let msg = ExtendedMessage::new("Compiling shaders…")
.with_duration_millis(2_000)
.with_target(MessageTarget::Osd)
.with_kind(MessageKind::Progress)
.with_progress(MessageProgress::Percent(50));
let _ = env.set_message_ext(msg);
}
Variants worth knowing:
MessageTarget::All,Osd,Log— where the message goes.MessageKind::Notification,NotificationAlt,Status,Progress.MessageProgress::IndeterminateorMessageProgress::Percent(0..=100).
Logger is acquired from either env.logger() or runtime.logger() and
exposes debug, info, warn, error.
Useful Runtime Queries
These are safe to call from run and return None (or false) when the
frontend has not negotiated the capability:
let lang = env.language(); // Option<Language>
let user = env.username(); // Option<String>
let fast = env.fastforwarding(); // Option<bool>
let throttle = env.throttle_state(); // Option<ThrottleState>
let av = env.audio_video_enable(); // Option<AvEnableFlags>
let dirs = env.save_directory(); // Option<String>
let jit = env.jit_capable(); // Option<bool>
let dupe = env.can_dupe_frames(); // Option<bool>
let dev = env.input_device_capabilities(); // Option<InputDeviceCapabilities>
Many of them are also useful at setup: query path directories early to preallocate buffers, query input device capabilities to refine controller info.
Service Discovery
Optional services are acquired by calling typed *_interface accessors on
Environment. Each returns Option<...>; treat None as “not exposed by
this frontend” and degrade gracefully.
let rumble = env.rumble_interface();
let led = env.led_interface();
let sensors = env.sensor_interface();
let perf = env.perf_interface();
let mic = env.microphone_interface();
let midi = env.midi_interface();
let location = env.location_interface();
let camera = env.camera_interface(CameraRequest::raw_framebuffer());
For details on each interface, see Frontend Services.
Setup vs Runtime
| Phase | Typical commands |
|---|---|
on_set_environment | set_* (pixel format, descriptors, controller info, variables/options, performance level, support flags, subsystems, memory maps, disk control, AV info hints) |
load_game | set_pixel_format, set_hw_render_from_candidates, set_serialization_quirks |
run (via runtime.environment()) | get_variable, variables_updated, set_message[_ext], set_geometry, set_system_av_info, throttle/fastforward queries, service acquisition |
| Any | logger, language, paths, message version, service is_available checks |
Some commands work in both phases but mean different things. Setting a
controller descriptor during on_set_environment declares the labels;
calling it from run typically only succeeds if the frontend is willing to
accept hot updates. When in doubt, check the return value.
Reference
- Core Options Translation — upstream source of truth for v0/v1/v2 differences.
- Frontend Services — service-by-service detail.
- Input and Events —
CoreEventConfigis the event surface for callbacks (keyboard, audio, camera, location, frame time).
Memory, Savestates, and Serialization
This chapter covers four related libretro surfaces:
- Memory regions — exposing save RAM, system RAM, VRAM to the frontend.
- Memory maps — describing the emulated address space so debuggers and cheat engines can attach.
- Savestates — serializing and restoring core state.
- Software framebuffers — writing directly into a frontend-provided pixel buffer.
A key design rule in this crate is that host pointers and emulated
addresses are never the same type. Use the newtypes
(EmulatedAddress, MemoryMapOffset, MemoryMapMask, MemoryMapLen) so
that reviewers can tell at a glance whether a number is a host offset, an
emulated address, a mask, or a length.
Exposing Memory Regions
Implement memory_region on the Core trait when a frontend should be
able to read save RAM, VRAM, or system RAM (for save files, debuggers,
achievements, or cheat overlays):
fn memory_region(&mut self, region: MemoryRegion) -> Option<CoreMemory<'_>> {
match region {
MemoryRegion::SaveRam => Some(CoreMemory::read_write(&mut self.save_ram)),
MemoryRegion::SystemRam => Some(CoreMemory::read_only(&self.work_ram)),
MemoryRegion::VideoRam => Some(CoreMemory::read_only(&self.vram)),
_ => None,
}
}
MemoryRegion covers SaveRam, Rtc, SystemRam, VideoRam, Rom, and
Unknown(u32). CoreMemory::read_only and read_write wrap the borrowed
slice and tell the frontend which access modes are allowed.
Return None from memory_region for any region the core does not
expose. Do not return a zero-length slice — the frontend reads None as
“unsupported”, which is the right behavior for unmapped regions.
Memory Map Descriptors
A memory map tells the frontend how regions of host memory line up against
the emulated address space. Register one with Environment::set_memory_maps
during setup:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let work_ram = MemoryMapDescriptor::from_slice(
Some("WRAM".to_string()),
EmulatedAddress::from(0xC000),
&mut self.work_ram,
)
.with_flags(MemoryDescriptorFlag::SystemRam.into())
.with_select(MemoryMapMask::from(0xE000))
.with_len(MemoryMapLen::from(0x2000));
let cartridge = MemoryMapDescriptor::new_inaccessible(
Some("ROM".to_string()),
EmulatedAddress::from(0x0000),
MemoryMapMask::from(0x8000),
);
let _ = env.set_memory_maps(&[work_ram, cartridge]);
}
Two constructor shapes:
from_slice(addrspace, start, &mut [u8])for a region with a real backing buffer. The pointer is borrowed for the lifetime of the descriptor.new_inaccessible(addrspace, start, select)for regions the core does not expose to debuggers (open bus, ROM, IO ports).
Builder methods on MemoryMapDescriptor:
| Method | Purpose |
|---|---|
with_flags(MemoryDescriptorFlags) | Constant, BigEndian, SystemRam, SaveRam, VideoRam. |
with_alignment(MemoryDescriptorAlignment) | TwoBytes, FourBytes, EightBytes. |
with_min_access_size(MemoryDescriptorMinAccessSize) | Smallest legal access width. |
with_offset(MemoryMapOffset) | Host-side offset into the backing buffer. |
with_select(MemoryMapMask) | Select mask in emulated address space. |
with_disconnect(MemoryMapMask) | Address lines that disconnect. |
with_len(MemoryMapLen) | Length of the mapped region in bytes. |
The wrapper retains all descriptor strings internally, so the slice you
pass to set_memory_maps can be a borrowed temporary.
Savestates
Three Core trait methods cover savestate serialization:
fn serialize_size(&self) -> usize {
self.core_state_size_bytes()
}
fn serialize(&self, data: &mut [u8]) -> bool {
self.write_state_into(data).is_ok()
}
fn unserialize(&mut self, data: &[u8]) -> bool {
self.read_state_from(data).is_ok()
}
serialize_size is called first; the frontend allocates that many bytes
and then calls serialize. On restore, unserialize receives the bytes
written previously. Both must return true on success and false on any
failure that should be surfaced to the user.
serialize_size should be deterministic for a given core configuration.
If it can change at runtime (variable-size cores), declare that explicitly
through serialization quirks (next section).
Savestate Context and Quirks
Savestates serve four different scenarios in libretro frontends, and not
all of them want identical behavior. Environment::savestate_context()
tells the core which scenario is active:
let mut env = runtime.environment();
match env.savestate_context() {
Some(SavestateContext::RunaheadSameInstance) => self.fast_serialize(),
Some(SavestateContext::RollbackNetplay) => self.netplay_serialize(),
_ => self.standard_serialize(),
}
SavestateContext variants: Normal, RunaheadSameInstance,
RunaheadSameBinary, RollbackNetplay, Unknown(i32).
If the core’s serialization has limitations, declare them so the frontend can avoid scenarios that would corrupt:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let quirks = SerializationQuirk::SingleSession
| SerializationQuirk::EndianDependent;
let _ = env.set_serialization_quirks(quirks);
}
SerializationQuirk flags: Incomplete, MustInitialize,
CoreVariableSize, FrontendVariableSize, SingleSession,
EndianDependent, PlatformDependent.
The return value of set_serialization_quirks is the subset of quirks the
frontend accepted — useful when negotiating with older frontends.
Cheats
Two optional Core trait methods cover cheat code application:
fn cheat_reset(&mut self) {
self.active_cheats.clear();
}
fn cheat_set(&mut self, index: CheatIndex, enabled: bool, code: Option<CheatCode<'_>>) {
let Some(code) = code else { return; };
if enabled {
let text = code.to_string_lossy();
self.active_cheats.insert(index.get(), text.into_owned());
} else {
self.active_cheats.remove(&index.get());
}
}
CheatIndex::new(u32) and CheatIndex::get() move between the wrapper and
raw indices. CheatCode<'_> borrows a NUL-terminated string from the
frontend; use as_c_str, to_str, or to_string_lossy to consume it.
Software Framebuffer
SoftwareFramebuffer is a frontend-provided pixel buffer that lets a
software core write directly into frontend memory instead of submitting a
copy. Request access through Environment::current_software_framebuffer:
fn run(&mut self, runtime: &mut Runtime<'_>) {
let request = SoftwareFramebufferRequest::new(WIDTH, HEIGHT)
.with_access(FramebufferMemoryAccess::Write.into());
let mut env = runtime.environment();
if let Some(mut fb) = env.current_software_framebuffer(request) {
let pitch = fb.pitch();
if let Some(bytes) = fb.bytes_mut() {
paint_frame(bytes, pitch);
}
let _ = runtime.video_refresh_frame(&self.cached_pixels, WIDTH, HEIGHT, pitch);
} else {
let _ = runtime.video_refresh_frame_with_audio(
&self.framebuffer, WIDTH, HEIGHT, self.pitch, &self.audio,
);
}
}
Useful SoftwareFramebuffer methods:
width(),height(),pitch(),format().access()— what the frontend granted (may be narrower than requested).memory()— cache flags such asCached.bytes()returnsSome(&[u8])only if read access is granted.bytes_mut()returnsSome(&mut [u8])only if write access is granted.
Always handle the None fallback path: not every frontend exposes a
software framebuffer, and the answer can change between frames.
Reference
- Runtime Video and Audio — normal frame submission helpers.
- Frontend Services — additional services such as rumble and LED that may interact with save data.
Storage, Disks, and VFS
Three storage-related libretro surfaces live in this chapter because they have different ownership models:
- Disk control is core-owned media state. The core decides what “the current disk” means and answers frontend queries.
- Subsystems describe load-time multi-ROM contracts. Registered once during environment setup.
- VFS is frontend-owned file access. The frontend exposes a filesystem interface and the core uses RAII handles to read and write.
Use normal Rust std::fs only when the core intentionally bypasses
frontend VFS policy (for example, debug-only paths). Frontend-mediated
paths should always go through VfsInterface.
Disk Control
Implement these Core trait methods when the core has swappable disk
images (CD-ROM emulators, multi-disc games):
impl Core for MyCore {
fn disk_image_count(&mut self) -> u32 {
self.disks.len() as u32
}
fn disk_image_index(&mut self) -> DiskIndex {
DiskIndex::new(self.current_disk)
}
fn disk_set_image_index(&mut self, index: DiskIndex) -> bool {
let i = index.get();
if (i as usize) < self.disks.len() {
self.current_disk = i;
true
} else {
false
}
}
fn disk_tray_state(&mut self) -> DiskTrayState {
if self.tray_open { DiskTrayState::Ejected } else { DiskTrayState::Closed }
}
fn disk_set_tray_state(&mut self, state: DiskTrayState) -> bool {
self.tray_open = matches!(state, DiskTrayState::Ejected);
true
}
fn disk_replace_image_index(
&mut self,
index: DiskIndex,
game: Option<GameInfo<'_>>,
) -> bool {
let i = index.get() as usize;
match game {
Some(info) => self.disks[i] = Disk::from_game_info(info),
None => self.disks.remove(i),
};
true
}
fn disk_add_image_index(&mut self) -> bool {
self.disks.push(Disk::empty());
true
}
fn disk_image_path(&mut self, index: DiskIndex) -> Option<String> {
self.disks.get(index.get() as usize)?.path.clone()
}
fn disk_image_label(&mut self, index: DiskIndex) -> Option<String> {
self.disks.get(index.get() as usize)?.label.clone()
}
}
Register the disk control interface during environment setup. There are two versions:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
if let Some(version) = env.disk_control_interface_version() {
if version.supports_extended() {
let _ = env.set_disk_control_ext_interface();
} else {
let _ = env.set_disk_control_interface();
}
} else {
let _ = env.set_disk_control_interface();
}
}
The extended (v1) interface enables disk_image_path, disk_image_label,
disk_set_initial_image, disk_add_image_index, and replace operations.
The v0 interface only supports basic tray/index management.
Subsystems
A subsystem describes how a core can load multiple related ROMs as a single “game” — for example, a Super Game Boy core that needs both an SNES ROM and a Game Boy ROM. Register during environment setup:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
let sgb = SubsystemInfo::new("Super Game Boy", "sgb", SubsystemId::from(0))
.with_roms([
SubsystemRomInfo::new("SNES ROM", "smc|sfc")
.with_required(true)
.with_memory([
SubsystemMemoryInfo::new("srm", SubsystemMemoryType::from(0x101)),
]),
SubsystemRomInfo::new("Game Boy ROM", "gb|gbc")
.with_required(true)
.with_need_fullpath(true)
.with_memory([
SubsystemMemoryInfo::new("sav", SubsystemMemoryType::from(0x101)),
SubsystemMemoryInfo::new("rtc", SubsystemMemoryType::from(0x102)),
]),
]);
let _ = env.set_subsystem_info(&[sgb]);
}
| Type | Purpose |
|---|---|
SubsystemInfo | One subsystem entry — description, identifier, numeric id, and the ROMs it loads. |
SubsystemRomInfo | One ROM slot within a subsystem — extensions, fullpath requirements, memory associations. |
SubsystemMemoryInfo | A save/RTC/SRAM file associated with a ROM slot. |
SubsystemMemoryType::from(u32) | Frontend-defined memory category (e.g. 0x101 for cartridge SRAM, 0x102 for RTC). |
The wrapper retains all descriptor strings, so the slice you pass to
set_subsystem_info can be built from temporaries.
VFS
VfsInterface is acquired during setup with the version the core needs:
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
self.vfs = env.vfs_interface(VfsInterfaceVersion::new(3));
}
Files
VfsFile is RAII — drop closes the file, or call close() explicitly to
inspect the result:
let Some(vfs) = self.vfs else { return; };
let access = VfsFileAccessFlags::from(VfsFileAccess::Read);
let hints = VfsFileAccessHints::empty();
let Some(mut file) = vfs.open_file("/path/to/save.srm", access, hints) else {
return;
};
let mut buffer = vec![0u8; 4096];
while let Some(read) = file.read(&mut buffer) {
if read == 0 { break; }
self.save_data.extend_from_slice(&buffer[..read]);
}
let _ = file.seek(0, VfsSeekPosition::Start);
let _ = file.tell();
let _ = file.flush();
// Drop closes the file; or:
let _ok = file.close();
VfsFileAccess variants: Read, Write, UpdateExisting. Combine with
| to get multi-mode flags. VfsSeekPosition is Start, Current, or
End.
Writing follows the same shape:
let access = VfsFileAccessFlags::from(VfsFileAccess::Write)
| VfsFileAccess::UpdateExisting;
let hints = VfsFileAccessHints::empty();
let Some(mut file) = vfs.open_file("/path/save.srm", access, hints) else {
return;
};
let _ = file.write(&self.save_data);
let _ = file.flush();
Directories
VfsDirectory iterates entries via read_next:
let Some(mut dir) = vfs.open_dir("/saves", /* include_hidden */ false) else {
return;
};
while dir.read_next() {
if let Some(name) = dir.entry_name() {
let is_dir = dir.entry_is_dir();
self.entries.push((name, is_dir));
}
}
Other operations
let exists = vfs.stat("/saves/slot1.srm"); // Option<VfsMetadata>
let ok = vfs.create_dir("/saves");
let ok = vfs.rename("/old.srm", "/new.srm");
let ok = vfs.remove_file("/scratch.bin");
VfsMetadata::flags is a VfsStatFlags bitmask (Valid, Directory,
CharacterSpecial); size is the file size when known.
VfsFile and VfsDirectory are !Send and !Sync — they hold raw
pointers into the frontend callback table and must stay on the thread
that called run.
Reference
- Memory, Savestates, and Serialization — for save state APIs that often pair with disk control (per-disk save snapshots).
- Content, AV, and Timing — primary content loading; subsystems extend it for multi-ROM cores.
Hardware Rendering and OpenGL
Hardware rendering is negotiated through Environment, then used through
Runtime and the typed Gl wrapper. The frontend owns the OpenGL context and
the current framebuffer. The core owns its GL objects and must recreate them
when the frontend recreates the context.
During load_game, request candidate contexts:
let mut env = runtime.environment();
let candidates = opengl_modern_preferred_hw_render_candidates();
if env.set_hw_render_from_candidates(&candidates).is_none() {
return false;
}
The built-in candidate sets are:
opengl_modern_preferred_hw_render_candidates(): generic OpenGL, explicit GLES 2.0, legacy GLES2, OpenGL core 3.3, then GLES3.opengl_compatibility_hw_render_candidates(): generic OpenGL, explicit GLES 2.0, then legacy GLES2.
When the frontend creates or recreates a context, hw_context_reset should load
symbols and rebuild GL objects:
fn hw_context_reset(&mut self, runtime: &mut Runtime<'_>) {
let gl = Gl::init(runtime)
.unwrap_or_else(|error| panic!("failed to initialize GL: {error}"));
self.gl = Some(gl);
}
Gl::init has staged behavior:
- Mandatory symbols: framebuffer binding, viewport, clear color, and clear.
- Optional symbols: shaders, buffers, textures, blending, vertex arrays, and richer GL helpers.
- Resource and draw methods return
Errwhen their feature is unavailable. - State restoration and cleanup methods no-op when their feature is unavailable.
That means a core can always try to present a clear-only diagnostic frame before probing richer rendering.
Per frame, call runtime.current_framebuffer() because the frontend-provided
FBO can change. Bind it with GlFramebuffer::from_raw(framebuffer), set the
viewport, render, restore shared GL state, and submit with
runtime.video_refresh_hw_with_audio(width, height, 0, &audio_frames).
Minimal frame:
let Some(framebuffer) = runtime.current_framebuffer() else {
let _ = runtime.video_refresh_dupe_with_audio(width, height, audio);
return;
};
gl.bind_framebuffer(
GlFramebufferTarget::Framebuffer,
GlFramebuffer::from_raw(framebuffer),
)?;
gl.viewport(GlRect::new(0, 0, width, height))?;
gl.clear_color(0.08, 0.09, 0.12, 1.0);
gl.clear_color_buffer();
gl.unbind_framebuffer(GlFramebufferTarget::Framebuffer);
let _ = runtime.video_refresh_hw_with_audio(width, height, 0, audio);
Typical buffer upload:
let vbo = gl.gen_buffer()?;
gl.bind_buffer(GlBufferTarget::ArrayBuffer, Some(vbo));
gl.buffer_data(
GlBufferTarget::ArrayBuffer,
vertices,
GlBufferUsage::StaticDraw,
)?;
gl.unbind_buffer(GlBufferTarget::ArrayBuffer);
Typical draw:
gl.use_program(Some(program));
gl.bind_buffer(GlBufferTarget::ArrayBuffer, Some(vbo));
gl.enable_vertex_attrib(position);
gl.vertex_attrib_pointer_f32(position, position_layout);
gl.draw_arrays(GlDrawMode::Triangles, GlDrawRange::from_start(vertex_count))?;
gl.disable_vertex_attrib(position);
gl.unbind_buffer(GlBufferTarget::ArrayBuffer);
gl.use_no_program();
Typical texture setup:
let texture = gl.gen_texture()?;
gl.active_texture(GlTextureUnit::ZERO)?;
gl.bind_texture(GlTextureTarget::Texture2D, Some(texture));
gl.tex_min_filter(GlTextureTarget::Texture2D, GlTextureMinFilter::Nearest);
gl.tex_mag_filter(GlTextureTarget::Texture2D, GlTextureMagFilter::Nearest);
gl.tex_image_2d(
GlTextureTarget::Texture2D,
GlTextureInternalFormat::Rgba,
GlTextureLevel::ZERO,
GlTextureSize2D::new(width, height),
GlTextureFormat::Rgba,
GlTextureDataType::UnsignedByte,
Some(rgba_bytes),
)?;
gl.unbind_texture(GlTextureTarget::Texture2D);
If hardware mode is active but a framebuffer or renderer resource is missing, submit a duplicate hardware frame with audio and surface a diagnostic. Software pixel fallback is a pre-negotiation path, not a post-negotiation frame path.
The OpenGL API follows recognizable command names with typed arguments:
GlBufferTarget,GlBufferUsage, and byte-size newtypes for buffers.GlTextureTarget, formats, filters, wraps, and dimensions for textures.GlFramebufferTarget, attachments, renderbuffer formats, and rectangles for framebuffer work.GlDrawMode,GlDrawRange, index types, and vertex layouts for drawing.
Common feature decisions:
if gl.supports_shader_pipeline() {
// Build or draw shader/buffer geometry.
}
if gl.supports_textures() {
// Upload textures or draw bitmap text.
}
if gl.supports_vertex_arrays() {
// Use VAOs for core-profile desktop GL.
}
The examples show two context strategies:
- Modern OpenGL core prefers modern desktop/GLES paths and falls back where needed.
- Compatibility OpenGL core uses a smaller compatibility profile and visible diagnostics.
Tutorial: OpenGL.
Reference: OpenGL Cores.
Frontend Services
Frontend services are optional capabilities a core discovers at runtime.
Each one is a typed handle acquired from Environment (or Runtime); if
the frontend does not expose the service, the accessor returns None and
the core should degrade gracefully rather than fail loudly.
let Some(rumble) = runtime.environment().rumble_interface() else {
return; // frontend has no rumble; skip haptics this frame
};
rumble.set_state(InputPort::from(0), RumbleEffect::Strong, RumbleStrength::max());
This chapter groups services by what they do. Event-shaped callbacks
(keyboard, audio callback, camera frames, location lifecycle, frame time)
are registered through CoreEventConfig instead — see
Input and Events.
Logging and Messages
Logger is the universal output for debug/info/warn/error lines. Acquire
it from Environment or Runtime; it has no is_available because the
frontend always exposes a log target (defaulting to stderr if needed).
runtime.logger().info("starting frame");
runtime.logger().warn(format!("fallback path: {reason}"));
For user-visible overlays, two helpers exist on Runtime and
Environment:
let _ = runtime.set_message("Save complete", 120); // text, frames
let mut env = runtime.environment();
let _ = env.set_message_ext(
ExtendedMessage::new("Compiling shaders…")
.with_duration_millis(2_000)
.with_target(MessageTarget::Osd)
.with_kind(MessageKind::Progress)
.with_progress(MessageProgress::Percent(50)),
);
See Environment and Core Options
for the full ExtendedMessage shape.
Rumble
if let Some(rumble) = env.rumble_interface() {
rumble.set_state(InputPort::from(0), RumbleEffect::Strong, RumbleStrength::max());
rumble.set_state(InputPort::from(0), RumbleEffect::Weak, RumbleStrength::new(0x4000));
}
RumbleEffect is Strong or Weak. RumbleStrength is a u16 newtype
with new, off, and max constructors. The port is the same player slot
used by joypad polling — see Input.
LED
if let Some(led) = env.led_interface() {
let _ = led.set_state(LedIndex::new(0), LedState::On);
}
LedIndex::new(i32) selects which LED to toggle and LedState is On or
Off. The mapping is frontend-specific.
Sensors
Per-port accelerometer, gyroscope, and illuminance sampling. Enable each sensor explicitly at the rate you want, poll the typed inputs each frame, disable when done.
if let Some(sensors) = env.sensor_interface() {
let port = InputPort::from(0);
let _ = sensors.enable(port, Sensor::Accelerometer, SensorRateHz::new(60));
let ax = sensors.input(port, SensorInput::AccelerometerX); // Option<f32>
let ay = sensors.input(port, SensorInput::AccelerometerY);
let az = sensors.input(port, SensorInput::AccelerometerZ);
let _ = sensors.disable(port, Sensor::Accelerometer);
}
Sensor selects which physical sensor to drive. SensorInput selects
which axis to read. Reads return Option<f32>; None means the sensor is
not enabled or not available.
Microphone
Microphone access is RAII-wrapped. Open the interface, open a stream, read samples while enabled, drop the handle when done.
let Some(interface) = env.microphone_interface() else { return; };
let Some(mut mic) = interface.open_default() else { return; };
mic.set_enabled(true);
let mut buffer = [0i16; 512];
match mic.read_samples(&mut buffer) {
Ok(count) => self.consume_audio(&buffer[..count]),
Err(MicrophoneReadError::Unavailable) => self.disable_microphone(),
Err(_) => {}
}
Use interface.open(MicrophoneParams::new(rate)) for a specific sample
rate. The Microphone handle is !Send; keep it on the core struct only
while you actually use it.
MIDI
Byte-level MIDI in/out with microsecond delta tracking:
if let Some(midi) = env.midi_interface() {
if midi.input_enabled() {
while let Some(byte) = midi.read_byte() {
self.midi_buffer.push(byte);
}
}
if midi.output_enabled() {
let _ = midi.write_byte(0x90, MidiDeltaMicros::from(0)); // note on, no delay
let _ = midi.flush();
}
}
midi.is_available() is a const check and useful for early-out before
calling other methods.
Location
GPS-style position polling.
if let Some(location) = env.location_interface() {
let _ = location.set_interval(
LocationIntervalMillis::from(1_000),
LocationIntervalMeters::from(5),
);
let _ = location.start();
if let Some(pos) = location.position() {
// pos.latitude, pos.longitude, pos.horizontal_accuracy, ...
}
let _ = location.stop();
}
Lifecycle callbacks (location_initialized, location_deinitialized) are
event-shaped — see Input and Events.
Camera
Camera control acquires the interface with the desired delivery mode (raw framebuffer or OpenGL texture):
let Some(camera) = env.camera_interface(CameraRequest::raw_framebuffer()) else {
return;
};
if camera.capabilities().contains(CameraCapability::RawFramebuffer) {
let _ = camera.start();
// Frame delivery arrives through the camera_raw_frame event listener.
}
CameraRequest::open_gl_texture() selects texture delivery and pairs with
the camera_texture_frame event. See
Input and Events for the
matching frame-callback shapes.
Performance Counters
PerfInterface exposes wall clock, tick counter, CPU feature flags, and
pinned counter management. See
Diagnostics and Performance for the full
walkthrough; here is the shape:
let perf = runtime.environment().perf_interface();
if let Some(perf) = perf {
let now_us = perf.time_micros(); // Option<PerfTimeMicros>
let features = perf.cpu_features(); // Option<CpuFeatures>
if features.map(|f| f.contains(CpuFeature::Avx2)).unwrap_or(false) {
self.use_simd_path();
}
}
Netplay Packets
When a core wants to participate in netplay, it installs a packet handler
through Environment and receives a NetpacketSession from the wrapper:
fn on_netpacket_receive(&mut self, session: &NetpacketSession, packet: Netpacket<'_>) {
let from = packet.client_id();
self.apply_remote_input(from, packet.data());
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
if let Some(session) = self.netpacket_session.as_ref() {
session.send(
NetpacketTarget::Broadcast,
NetpacketFlags::reliable(),
&self.local_input_packet(),
);
session.flush(NetpacketTarget::Broadcast);
}
}
NetpacketTarget is Client(NetplayClientId) or Broadcast.
NetpacketFlags is built with reliable(), unreliable(), and
unsequenced() constructors that can be combined.
Frontend State Queries
These are not “services” in the discovery sense, but they are how cores
adapt to frontend mode. All return Option<...>:
let mut env = runtime.environment();
let lang = env.language(); // localized strings
let user = env.username(); // player profile
let fast = env.fastforwarding(); // skip non-essential work
let throttle = env.throttle_state(); // explicit target rate
let power = env.device_power(); // battery-aware policies
let av = env.audio_video_enable(); // selective frame skip
Use them to choose conservative paths when the frontend is fast-forwarding or running on low battery, and to load locale-aware UI strings.
Tutorial Cross-Links
- Input, Audio, OpenGL for the corresponding subsystem tutorials.
- Input and Events for event-shaped notifications.
- Compatibility OpenGL example is the reference for performance counters + diagnostics in a real core.
Diagnostics and Performance
A core that fails silently is hard to debug. This crate ships two companion tools:
libretro-diagnostics— visible failure frames, staged GL bring-up, and text overlays. Use it so the user sees why a core is unhappy instead of a black screen.- The
perfmodule insidelibretro-core— typed access to the frontend performance counter interface, plus CPU feature detection.
Both are optional. Add them when a core can fail in ways the user cannot diagnose otherwise (graphics init, frontend symbol availability, frame budget) or when you need to measure where time is going.
Software Diagnostic Frame
render_software_diagnostic_xrgb8888_frame fills a CPU-side framebuffer
with a gradient background, border, and wrapped diagnostic text. Use it as
the failure path before hardware rendering has been negotiated:
use libretro_diagnostics::render_software_diagnostic_xrgb8888_frame;
fn present_init_failure(&mut self, runtime: &mut Runtime<'_>, frame_index: u64) {
render_software_diagnostic_xrgb8888_frame(
&mut self.diagnostic_frame, // Vec<u32>, resized in place
WIDTH,
HEIGHT,
frame_index,
&["my-core 0.1", "Stage 1: load shaders"],
"OpenGL is not available. Falling back to software diagnostic.",
);
let pitch = WIDTH as usize * core::mem::size_of::<u32>();
let _ = runtime.video_refresh_frame_with_audio(
bytemuck::cast_slice(&self.diagnostic_frame),
WIDTH,
HEIGHT,
pitch,
&self.silence,
);
}
The header lines render at the top of the frame; the message is wrapped
into the body. The function resizes the Vec<u32> to match the requested
dimensions, so the same buffer can be reused frame to frame.
wrap_diagnostic_message(text, max_columns) produces the same line
wrapping standalone if you need it elsewhere.
Staged GL Initialization
After hardware rendering has been negotiated, use StagedDiagnosticGl to
load just enough GL for a clear-only diagnostic frame. The full Gl
facade comes back inside the staged value, and richer renderer setup can
fail independently without taking the diagnostic surface with it.
use libretro_diagnostics::StagedDiagnosticGl;
fn hw_context_reset(&mut self, runtime: &mut Runtime<'_>) {
let logger = runtime.logger();
let Some(staged) = StagedDiagnosticGl::init(runtime, logger, "my-core") else {
// Even the minimal clear path is unavailable. Fall back to software.
self.gl = None;
return;
};
let gl = staged.gl.clone();
self.gl = Some(gl);
// Try richer setup next; failures here keep the clear-only path alive.
match TriangleRenderer::new(self.gl.as_ref().unwrap()) {
Ok(triangle) => self.triangle = Some(triangle),
Err(err) => {
runtime.logger().error(format!("triangle init failed: {err}"));
}
}
}
StagedDiagnosticGl::init(runtime, logger, component) returns None if
the mandatory clear/framebuffer/viewport symbols cannot load. When it
returns Some(_), the gl field is a fully loaded Gl and the optional
shader/buffer/texture symbols can be probed with the usual
gl.supports_*() checks.
Text Overlay
DiagnosticTextOverlay renders bitmap text into a hardware frame using
shader and texture symbols. Build it once when richer GL features are
available, then update its lines and draw each frame:
use libretro_diagnostics::{
DiagnosticTextLayout, DiagnosticTextOverlay,
};
if gl.supports_textures() && gl.supports_shader_pipeline() {
let lines = ["FPS: 60.0", "Frame: 16.67 ms"];
let layout = DiagnosticTextLayout::new(12.0, 16.0, 1.0);
self.text_overlay = DiagnosticTextOverlay::new_with_layout(
&gl, &gl, &lines, layout,
).ok();
}
// Each frame, after rendering the main scene:
if let Some(overlay) = self.text_overlay.as_mut() {
let lines = self.format_perf_lines();
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let _ = overlay.update_lines(&gl, &line_refs);
let _ = overlay.draw(&gl, &gl, WIDTH, HEIGHT, [1.0, 0.91, 0.35, 1.0]);
}
DiagnosticTextLayout::DEFAULT is (12.0, 16.0, 1.0); new(x, y, scale)
overrides the position and scale. draw() takes RGBA in the [0.0, 1.0]
range. Call overlay.destroy(&gl, &gl) in hw_context_destroy to free
GPU resources before the context goes away.
Two GL handles are passed (gl and text_gl). They are usually the same
context but the API leaves room for splitting the overlay into a separate
GL context if a frontend ever requires that.
CPU Features
CpuFeatures is a BitFlags<CpuFeature> set queried through
PerfInterface:
let mut env = runtime.environment();
let Some(perf) = env.perf_interface() else { return; };
let Some(features) = perf.cpu_features() else { return; };
if features.contains(CpuFeature::Avx2) {
self.audio_path = AudioPath::Avx2;
} else if features.contains(CpuFeature::Sse2) {
self.audio_path = AudioPath::Sse2;
} else if features.contains(CpuFeature::Neon) {
self.audio_path = AudioPath::Neon;
} else {
self.audio_path = AudioPath::Scalar;
}
Variants include Sse, Sse2, Sse3, Ssse3, Avx, Avx2, Neon,
and several other architecture extensions. Cache the chosen path on the
core struct so the per-frame run doesn’t re-probe.
Performance Counters
A PerfCounter is a frontend-pinned counter. The lifecycle is
construct → register → start/stop pairs → read:
use std::pin::Pin;
use libretro::{PerfCounter, PerfInterface};
struct ProfiledCore {
perf: Option<PerfInterface>,
cpu_step: Pin<Box<PerfCounter>>,
}
impl Default for ProfiledCore {
fn default() -> Self {
Self {
perf: None,
cpu_step: PerfCounter::new("my_core_cpu_step"),
}
}
}
impl Core for ProfiledCore {
fn on_set_environment(&mut self, env: &mut Environment<'_>) {
if let Some(perf) = env.perf_interface() {
let registered = perf.register_counter(self.cpu_step.as_mut());
if registered {
self.perf = Some(perf);
}
}
}
fn run(&mut self, runtime: &mut Runtime<'_>) {
if let Some(perf) = self.perf.as_ref() {
let _ = perf.start_counter(self.cpu_step.as_mut());
}
self.advance_one_frame();
if let Some(perf) = self.perf.as_ref() {
let _ = perf.stop_counter(self.cpu_step.as_mut());
}
let total_ticks = self.cpu_step.total().as_ticks();
let call_count = self.cpu_step.call_count();
runtime.logger().debug(format!(
"step: {total_ticks} ticks across {call_count} calls",
));
}
}
A few important rules:
PerfCounter::newreturnsPin<Box<Self>>. The frontend stores the raw pointer when the counter is registered, so the value must not move.- All
register/start/stop_countercalls takePin<&mut PerfCounter>; usecounter.as_mut()(wherecounterisPin<Box<_>>) to obtain that. - Counters are unitless ticks. Pair them with
PerfTimeMicrosfromperf.time_micros()if a wall-clock measurement is needed.
PerfInterface::log() asks the frontend to dump all registered counters
to its log target — useful from a debug shortcut or unload_game.
When To Use Each Tool
| Situation | Tool |
|---|---|
| Hardware negotiation rejected | Software diagnostic frame + frontend message. |
| GL context exists but advanced symbols missing | StagedDiagnosticGl + clear-only HW frame. |
| Need to surface per-frame timing | DiagnosticTextOverlay driven by PerfCounter totals. |
| SIMD path selection | CpuFeatures once at setup, cached on the core. |
| Locating slow code | PerfCounter start/stop around suspected blocks. |
For a complete end-to-end example combining staged GL init, software fallback, diagnostic text, and live perf counters, read the Compatibility OpenGL core walkthrough and source.
Raw ABI Boundaries
The raw libretro.h mapping in this crate exists so the wrapper can
preserve ABI contracts exactly. It is not the normal core-author API.
Most cores never need to touch raw types.
The raw module lives at crates/libretro-core/src/raw.rs and is
auto-generated from upstream libretro.h. Layout assertions live next to
each typed wrapper that crosses the ABI boundary so a future bindgen
update cannot silently break field offsets.
When To Reach For Raw Types
Three legitimate reasons:
- Auditing the wrapper. Reading the typed API alongside the raw bindings is the fastest way to confirm a callback ordering, retained string, or nullable function pointer is handled correctly.
- Layout tests. When a new typed feature wraps an upstream struct,
add
static_assert-style tests that compare offsets between the typed and raw versions. Existing examples live incrates/libretro-core/tests/. - Adding a new typed feature. When
libretro.hexposes something the wrapper does not yet model, the raw types are how you reach it during prototyping. The result should land as a typed API; do not leave raw types in the public surface.
For everything else, prefer:
Coreandexport_core!instead of writingretro_*exports by hand.Environmentmethods instead of rawRETRO_ENVIRONMENT_*command numbers.Runtimehelpers instead of direct callback function pointers.- Typed enums and newtypes (
JoypadButton,PixelFormat,MemoryRegion,EmulatedAddress) instead ofu32IDs and bitmasks. - Service interfaces (
VfsInterface,MidiInterface,MicrophoneInterface,PerfInterface, etc.) instead of poking callback tables.
Rules When Adding A New Mapping
A typed feature is correct only if it preserves the upstream contract. Before adding a builder, check each of these:
| Concern | What to verify |
|---|---|
| Pointer lifetimes | Frontend keeps the pointer for how long? Does the wrapper need to retain backing storage (StringPool, SubsystemInfoStorage, etc.)? |
| Nullable function pointers | Some retro_*_callback fields are Option<unsafe extern "C" fn(...)>. Treat None as “frontend didn’t install this”. |
| Callback ordering | RETRO_ENVIRONMENT_SET_*_CALLBACK calls usually have to land before the corresponding feature is used, sometimes only between specific lifecycle events. |
| Retained strings | Strings passed to the frontend must outlive whatever struct holds the pointer. Use the retained CString storage patterns already in options.rs and subsystem.rs. |
| Multi-step contracts | Some environment commands negotiate in two passes (request → frontend writes back). Wrap both steps in one typed method. |
When in doubt, mirror an existing wrapper that handles the same shape:
set_subsystem_info for retained slices, set_core_options_v2 for
retained nested structures, vfs_interface for RAII handles, and
set_message_ext for typed enum payloads.
What Lives Where
| Module | Purpose |
|---|---|
crates/libretro-core/src/raw.rs | Auto-generated bindings to libretro.h. |
crates/libretro-core/src/glsym_raw.rs | Raw GL symbol table layout used by Gl::init. |
crates/libretro-core/libretro_coverage.md | Tracker mapping libretro.h categories to typed coverage. |
The coverage tracker is the right place to look before adding a new typed feature — it shows what is already wrapped, what is partially wrapped, and what is still raw-only.
Hello World Core
Source: examples/hello-libretro
The hello example is the first smoke-test core. It demonstrates:
- a
cdylibpackage, Coreplusexport_core!,- no-game startup,
- fixed 320x240 / 60 FPS / 48 kHz AV info,
- a solid blue default-format software frame,
- silent stereo audio.
The source intentionally stays tiny. For the reusable minimal lifecycle, use
examples/software-libretro, which keeps frame/audio buffers
in core state and applies its content contract consistently to metadata and the
environment.
Tutorial: Hello World Core.
Software Core
Source: examples/software-libretro
The software example is the smallest complete core in the workspace. It shows:
SystemInfoandContentContractsetup,- fixed geometry and timing through
fixed_system_av_info, - no-game support,
- one software framebuffer format,
- one audio batch per video frame,
runtime.poll_input()before frame submission.
The frame is a constant blue 0RGB1555 buffer. The audio path uses
silent_stereo_frames_for_video_frame(48_000, 60) so the number of frames per
video frame is derived from the same timing contract reported to the frontend.
Use this example when validating lifecycle behavior before adding OpenGL, options, or advanced frontend services.
Copy this pattern for the first reusable software core:
- keep the framebuffer and audio batch in core state,
- use one
content_contract()helper forsystem_infoandon_set_environment, - call
runtime.poll_input()at the start ofrun, - submit with
video_refresh_frame_with_audio.
Tutorials:
Modern OpenGL Core
Source: examples/demo-libretro
The demo example is a hardware-rendered core with input and audio feedback. It shows:
- content/no-game handling,
- modern preferred OpenGL context negotiation,
- typed GL loading with
Gl::init, - shader/program/buffer/vertex-array setup,
- typed joypad polling,
- generated audio mixed into a silent frame batch,
- hardware frame submission with
video_refresh_hw_with_audio, - fallback duplicate-frame submission when hardware state is unavailable.
The triangle changes color based on whether content was supplied and moves with joypad input. This makes it a useful smoke test for content loading, input, OpenGL, and audio pacing in one core.
Lifecycle map:
load_gamelogs optional content, setsPixelFormat::Xrgb8888, and requestsopengl_modern_preferred_hw_render_candidates().runpolls joypad input, mixes a short sound effect into a silent audio batch, renders intoruntime.current_framebuffer(), and submitsvideo_refresh_hw_with_audio.hw_context_resetloadsGl, picks shader sources for the negotiated GL family, and creates the program, buffer, and optional vertex array.hw_context_destroydeletes GL-owned objects and clears cached handles.
Fallbacks in this demo keep audio moving with duplicate frames. For visible OpenGL diagnostics, use the compatibility example.
Tutorials:
Compatibility OpenGL Core
Source: examples/retrocompat-libretro
The compatibility example targets a conservative OpenGL path and emphasizes diagnosability. It shows:
- compatibility hardware-render candidates,
- staged GL initialization,
- visible software fallback when hardware negotiation fails,
- distinct clear colors for initialization failures,
- diagnostic text overlays,
- performance sampling and display,
- GL cleanup on unload/reset.
Use this example when working on frontend compatibility or when adding diagnostic behavior to a hardware core. It demonstrates the project rule that failures should be visible and actionable instead of producing a black frame.
Failure-mode map:
- If hardware negotiation is rejected, the core returns
truefromload_game, shows a frontend message, and presents a software diagnostic frame. - If hardware mode is accepted but no framebuffer is available, it shows a message and submits a duplicate frame with audio.
- If only clear symbols load, it still presents a clear-only hardware frame.
- If triangle rendering works but text setup fails, it keeps the triangle path alive and disables only the text overlay.
- If text rendering later fails, it destroys the text overlay and continues.
libretro-diagnostics provides the staged GL helpers and text/frame diagnostic
building blocks used by this example.
Tutorial: OpenGL.
Local Libretro Specs
The spec/ directory contains local Markdown copies of upstream libretro docs
used during implementation and review. Start from the index:
The current local references are:
spec/input-api.mdspec/developing-cores.mdspec/dynamic-rate-control.mdspec/opengl-cores.mdspec/core-options-translation.md
These are reference documents, not replacement tutorials. Book chapters should summarize the workflow for this Rust API and link to the spec when the upstream libretro contract is the important source of truth.
Rustdoc Map
Rustdoc is the precise API reference; this book is the guided manual. Generate local Rustdoc with:
cargo doc --workspace --no-deps
Primary entry points:
libretro::Corelibretro::Runtimelibretro::Environmentlibretro::CoreEventConfiglibretro::ContentContractlibretro::SystemInfolibretro::SystemAvInfolibretro::CoreOptionslibretro::MemoryRegionlibretro::VfsInterfacelibretro::HwRenderConfiglibretro::Gllibretro_diagnostics::StagedDiagnosticGllibretro_diagnostics::DiagnosticTextOverlay
The coverage tracker maps libretro.h categories to the Rust API surface:
Publishing and Validation
The book is a standard mdBook:
mdbook build book
Do not commit generated book/book/ output. CI builds it and publishes the
artifact.
The documentation gate should run:
cargo test --workspace
cargo doc --workspace --no-deps
mdbook build book
Publishing is handled by GitHub Actions on pushes to main. Pull requests build
the same source without deploying, so broken chapters, missing mdBook files, and
Rustdoc failures are visible before merge.