Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 as libretro, owns the Core trait, export_core! macro, typed libretro wrappers, runtime helpers, input polling, callback event routing, environment commands, and OpenGL symbol wrappers.
  • libretro-diagnostics owns 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

ConceptWhat it meansWhere to learn more
Core structYour persistent state: content, framebuffer, audio buffers, renderer handles, counters, and emulator/game state. The library does not provide this struct.Core Lifecycle
Core traitThe 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
SystemInfoMetadata such as core name, version, and supported content extensions.Content, AV, and Timing
ContentContractReusable declaration of content extensions and whether the core can start without content.Content, AV, and Timing
EnvironmentSetup and frontend-negotiation commands: content support, pixel format, options, controller info, hardware rendering, messages, services.Environment and Core Options
RuntimeTemporary 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
SystemAvInfoVideo geometry, FPS, and audio sample rate reported to the frontend.Content, AV, and Timing
Frame loopThe work done in Core::run: poll input, advance one frame of state, submit video and audio.Frame Loop Basics
Hardware renderingOptional OpenGL/GLES context negotiation and typed Gl rendering.OpenGL

Step By Step

  1. 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.

  2. 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.

  3. Implement the Core trait for your struct. Start with system_info, av_info, load_game, and run. Add optional lifecycle methods only when needed. See Libretro In Rust.

  4. Describe content support with ContentContract. Use it in both system_info and on_set_environment so metadata and frontend negotiation stay consistent. See Content, AV, and Timing.

  5. Report video and audio timing with SystemAvInfo. Most fixed-size cores can use fixed_system_av_info(width, height, fps, sample_rate). See Audio for sample pacing.

  6. Use Environment during setup. Register content support, input descriptors, controller info, core options, pixel format, hardware rendering, or frontend services from on_set_environment and load_game. See Environment and Core Options.

  7. Accept or reject content in load_game. Inspect GameInfo if content was supplied, initialize core state, and return true only when the core can run. See Hello World Core for no-content startup and Software Core for a reusable minimal lifecycle.

  8. Write the frame loop in run. Use the supplied Runtime: call poll_input, read input, advance one frame, and submit video plus audio. Keep Runtime out of your core struct. See Runtime Video and Audio, Frame Loop Basics, and Input.

  9. 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.

  10. Validate and run it. Use cargo test --workspace in this repo, build the core as a dynamic library, then load it in a frontend. See Running A Core and Publishing and Validation.

  11. 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 conceptRust API
Core metadataCore::system_info, SystemInfo, ContentContract
Environment callbackCore::on_set_environment, Environment
Content loadingCore::load_game, GameInfo
AV informationCore::av_info, SystemAvInfo
Per-frame executionCore::run, Runtime
Hardware context resetCore::hw_context_reset, Gl::init
Hardware context destroyCore::hw_context_destroy
ABI symbol exportslibretro::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:

  1. The frontend asks for system_info.
  2. The frontend supplies the environment callback, which dispatches on_set_environment.
  3. The frontend calls load_game.
  4. The frontend asks for av_info.
  5. The frontend repeatedly calls run.
  6. The frontend later calls unload_game and deinit.

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 false from load_game for invalid required content,
  • use runtime.logger() or runtime.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:

  1. Poll input.
  2. Advance core state.
  3. Produce video.
  4. Produce audio.
  5. 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_pressed for individual buttons, joypad_buttons for a bitmask when scanning several buttons from the same port.
  • Analog: analog_axis returns signed libretro axis values; analog_button returns analog button pressure.
  • Mouse: mouse_axis returns relative movement since the last poll.
  • Pointer: pointer_axis, pointer_pressed, pointer_count, and pointer_is_offscreen represent absolute touch or pen-like input.
  • Lightgun: use LightgunAxis::ScreenX, LightgunAxis::ScreenY, LightgunButton::Trigger, LightgunButton::Reload, and lightgun_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:

  1. Pick video and audio timing in av_info.
  2. Keep an audio buffer in the core struct.
  3. Fill that buffer during run.
  4. 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:

  1. Clear the frontend FBO and submit a hardware frame.
  2. Add shader and buffer setup for one triangle.
  3. Add texture upload only after the triangle path is stable.
  4. Add text or richer renderer state last.
  5. 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:

  1. system_info returns a stable SystemInfo.
  2. on_set_environment registers content support, options, callbacks, and frontend capabilities.
  3. load_game accepts or rejects content and performs runtime setup.
  4. av_info returns fixed or dynamic geometry/timing.
  5. run polls input, advances emulation, and submits video/audio.
  6. unload_game and deinit release 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 stepCore methodMain handle
Metadata querysystem_infoSystemInfo
Environment setupon_set_environmentEnvironment
Content loadload_gameRuntime
AV queryav_infoSystemAvInfo
Frame executionrunRuntime
Content unloadunload_gamecore state
Core shutdowndeinitcore 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:

FlagMeaning
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:

FieldTypeNotes
geometry.base_width / base_heightu32The current visible frame size.
geometry.max_width / max_heightu32The largest frame the core will ever submit. Frontend may allocate for this size.
geometry.aspect_ratiof32Use 0.0 to let the frontend derive aspect from base size.
timing.fpsf64Target frame rate.
timing.sample_ratef64Audio 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

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:

JobExamples
Inputpoll_input, joypad_pressed, joypad_buttons, analog_axis, mouse_axis, pointer_pressed
Videovideo_refresh_frame_with_audio, video_refresh_hw_with_audio, video_refresh_dupe_with_audio
Audioaudio_sample, audio_sample_batch, combined video/audio helpers
Hardware renderingcurrent_framebuffer, hw_proc_address through Gl::init
Messages and logsset_message, logger
Environmentenvironment() for runtime-valid environment commands
Frontend servicesrumble, 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:

SituationHelper
Software pixels plus audiovideo_refresh_frame_with_audio
Hardware-rendered frame plus audiovideo_refresh_hw_with_audio
Duplicate previous frame plus audiovideo_refresh_dupe_with_audio
Separate accountingvideo_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 notificationRegisterRemove or clearCallback method shape
Keyboard key/text eventadd_keyboard_event_listener(Self::keyboard_event)remove_keyboard_event_listener(Self::keyboard_event)fn keyboard_event(&mut self, event: KeyboardEvent)
Audio callback requestadd_audio_callback_listener(Self::audio_callback)remove_audio_callback_listener(Self::audio_callback)fn audio_callback(&mut self)
Audio callback active state changedadd_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 changedadd_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 reportedset_frame_time_callback(reference, Self::frame_time)clear_frame_time_callback()fn frame_time(&mut self, elapsed: FrameTime)
Location initializedadd_location_initialized_listener(Self::location_initialized)remove_location_initialized_listener(Self::location_initialized)fn location_initialized(&mut self)
Location deinitializedadd_location_deinitialized_listener(Self::location_deinitialized)remove_location_deinitialized_listener(Self::location_deinitialized)fn location_deinitialized(&mut self)
Camera initializedadd_camera_initialized_listener(Self::camera_initialized)remove_camera_initialized_listener(Self::camera_initialized)fn camera_initialized(&mut self)
Camera deinitializedadd_camera_deinitialized_listener(Self::camera_deinitialized)remove_camera_deinitialized_listener(Self::camera_deinitialized)fn camera_deinitialized(&mut self)
Camera raw frameadd_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 frameadd_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:

  • KeyboardEvent has down, key, character, and modifiers. Use KeyboardCharacter::as_char() when you need layout-aware text.
  • AudioCallbackState is Active or Inactive. The request callback has no extra payload; it means the frontend is asking the core to produce audio for callback-driven audio mode.
  • AudioBufferStatus has active, occupancy, and underrun_likely. occupancy.percent() returns Some(0..=100) for normal frontend values; raw_percent() preserves out-of-range frontend data for diagnostics.
  • FrameTime stores signed microseconds. Use FrameTime::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<'_> with pixels, width, height, and pitch_bytes, or CameraTextureFrame with texture_id, texture_target, and a 3x3 affine transform.

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_environment and load_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::Indeterminate or MessageProgress::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

PhaseTypical commands
on_set_environmentset_* (pixel format, descriptors, controller info, variables/options, performance level, support flags, subsystems, memory maps, disk control, AV info hints)
load_gameset_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
Anylogger, 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

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:

MethodPurpose
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 as Cached.
  • bytes() returns Some(&[u8]) only if read access is granted.
  • bytes_mut() returns Some(&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

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]);
}
TypePurpose
SubsystemInfoOne subsystem entry — description, identifier, numeric id, and the ROMs it loads.
SubsystemRomInfoOne ROM slot within a subsystem — extensions, fullpath requirements, memory associations.
SubsystemMemoryInfoA 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

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 Err when 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:

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.

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 perf module inside libretro-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::new returns Pin<Box<Self>>. The frontend stores the raw pointer when the counter is registered, so the value must not move.
  • All register/start/stop_counter calls take Pin<&mut PerfCounter>; use counter.as_mut() (where counter is Pin<Box<_>>) to obtain that.
  • Counters are unitless ticks. Pair them with PerfTimeMicros from perf.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

SituationTool
Hardware negotiation rejectedSoftware diagnostic frame + frontend message.
GL context exists but advanced symbols missingStagedDiagnosticGl + clear-only HW frame.
Need to surface per-frame timingDiagnosticTextOverlay driven by PerfCounter totals.
SIMD path selectionCpuFeatures once at setup, cached on the core.
Locating slow codePerfCounter 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:

  1. 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.
  2. 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 in crates/libretro-core/tests/.
  3. Adding a new typed feature. When libretro.h exposes 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:

  • Core and export_core! instead of writing retro_* exports by hand.
  • Environment methods instead of raw RETRO_ENVIRONMENT_* command numbers.
  • Runtime helpers instead of direct callback function pointers.
  • Typed enums and newtypes (JoypadButton, PixelFormat, MemoryRegion, EmulatedAddress) instead of u32 IDs 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:

ConcernWhat to verify
Pointer lifetimesFrontend keeps the pointer for how long? Does the wrapper need to retain backing storage (StringPool, SubsystemInfoStorage, etc.)?
Nullable function pointersSome retro_*_callback fields are Option<unsafe extern "C" fn(...)>. Treat None as “frontend didn’t install this”.
Callback orderingRETRO_ENVIRONMENT_SET_*_CALLBACK calls usually have to land before the corresponding feature is used, sometimes only between specific lifecycle events.
Retained stringsStrings 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 contractsSome 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

ModulePurpose
crates/libretro-core/src/raw.rsAuto-generated bindings to libretro.h.
crates/libretro-core/src/glsym_raw.rsRaw GL symbol table layout used by Gl::init.
crates/libretro-core/libretro_coverage.mdTracker 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 cdylib package,
  • Core plus export_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:

  • SystemInfo and ContentContract setup,
  • 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 for system_info and on_set_environment,
  • call runtime.poll_input() at the start of run,
  • 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_game logs optional content, sets PixelFormat::Xrgb8888, and requests opengl_modern_preferred_hw_render_candidates().
  • run polls joypad input, mixes a short sound effect into a silent audio batch, renders into runtime.current_framebuffer(), and submits video_refresh_hw_with_audio.
  • hw_context_reset loads Gl, picks shader sources for the negotiated GL family, and creates the program, buffer, and optional vertex array.
  • hw_context_destroy deletes 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 true from load_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:

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::Core
  • libretro::Runtime
  • libretro::Environment
  • libretro::CoreEventConfig
  • libretro::ContentContract
  • libretro::SystemInfo
  • libretro::SystemAvInfo
  • libretro::CoreOptions
  • libretro::MemoryRegion
  • libretro::VfsInterface
  • libretro::HwRenderConfig
  • libretro::Gl
  • libretro_diagnostics::StagedDiagnosticGl
  • libretro_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.