RustyNES Libretro Core Integration Architecture¶
Executive Overview¶
The Libretro API is a C ABI that separates an emulation engine from its
host (windows, audio devices, controllers). This document describes how the
RustyNES core, a pure-Rust, cycle-accurate NES emulator on a single
master clock, is packaged as a libretro core. It is the spec for
crates/rustynes-libretro; when the two disagree, the code and its tests win
and this file is corrected.
Target Workspace and Compile Targets¶
rustynes-core is #![no_std] + alloc; it knows nothing of the host. The
wrapper crate rustynes-libretro builds with
crate-type = ["cdylib", "staticlib"]:
cdylib— the shared library RetroArchdlopens /LoadLibrarys (rustynes_libretro.so/.dll/.dylibafter the crateMakefile's copy step). Its exports are the 50retro_*entry points plus__retro_init_core, the link-time hookrust-libretro'sretro_core!generates (docs/audits/libretro-disposition.mdL-3.3).staticlib— for the platforms whose RetroArch links cores statically (iOS, tvOS).panic = "unwind"— the libretro core is built to unwind, on every supported path (the crateMakefile, the libretro buildbot, thelibretro-crossCI gate), so a panic can be contained at the core's boundary instead of aborting RetroArch. The workspace release profile aborts; a directcargo build --releaseof this crate warns (build.rs) and logs it at load. The desktop and web builds keep aborting.no_stdpreserved —rustynes-coreis a path dependency withdefault-features = false; the wrapper usesstdfor the host side only.platform=libnxis dropped (v2.9.8, maintainer decision): the crateMakefilenow stops with an explicit$(error ...)rather than fall through to a host build. The reasoning, from the v2.9.0 re-audit (L-3.2 side note): it mapped toaarch64-nintendo-switch-freestanding, a tier-3 target:rustc --print target-listknows it,rustup target listoffers norust-stdfor it, and a*-freestandingtarget has nostd, while this wrapper needsstd(std::fsfor the FDS BIOS and disk saves,CString,std::panic::catch_unwind). Not attempted: a nightly-Zbuild-stdbuild, which is the only way to try it. The libretro buildbot never reads the Makefile's platform table and has no Rust template for the Switch (.gitlab-ci.ymlheader), so no shipped build is affected, and the buildbot matrix is unchanged. A fix would need a nightly-Zbuild-stdbuild and ano_stdwrapper withoutcatch_unwind, which the panic-containment design depends on.
The Abstraction Layer (rust-libretro)¶
rust-libretro-sys— the rawbindgenC types. Vendored and patched invendor/rust-libretro-sys(v2.8.0): the crates.io 0.3.2 binding reducedstruct retro_game_infoto one opaque byte, so the standard load path could not work; the vendored copy writes the struct out by hand (vendor/rust-libretro-sys/VENDORED.md, credited inNOTICE).rust-libretro— theCoretrait and the exportedretro_*trampolines, from crates.io. It keeps one process-global core instance, created on the firstretro_get_system_infoand never freed.
System Topology & State Management¶
1. The Libretro Frontend (RetroArch)¶
Draws the picture, opens the audio device, reads controllers, and calls
retro_run once per video frame (~60.0988 Hz NTSC, ~50.007 Hz PAL; the core
reports the loaded cartridge's region in retro_get_system_av_info).
2. The FFI Bridge (rustynes-libretro)¶
RustyNesLibretro implements rust_libretro::core::Core.
- State:
nes: Option<Nes>for an ordinary cartridge, ordual: Option<Box<VsDualSystem>>for one of the four Vs.DualSystemcabinet boards (two cross-wired consoles, presented side by side at 512x240). Exactly one isSomewhile a game is loaded (Emu::from_romdecides). - Buffers: reusable video, audio and serialize buffers, sized once, so a
retro_rundoes not allocate in steady state. Three exceptions, all measured or bounded: a Vs.DualSystemcabinet's serialize reuses the same buffer throughVsDualSystem::snapshot_intosince v2.9.1 (NL-09; it built a fresh ~645 KB state with thumbnails on every call before, about 8x slower,docs/performance.md), and a dual RESTORE still allocates one console's backup per call, measured as no cost worth pooling; and an FDS game that has written to its disk has the image built once, a second after the write, to save it (persist_fds_disk). The serialize buffer is filled at load by the snapshot that sizesretro_serialize_size-- for a single console with the expansion-device headroom reserved; a Vs. cabinet attaches no device, so its size is the snapshot's exact length -- so noretro_serializegrows it: until v2.9.2 the first one did, during the first run-ahead or rollback frame (audit AUD-17). A cabinet's 512x240 image is composed over the previous frame's, every byte rewritten, with no per-frame zero-fill (AUD-19). - Containment: every callback that runs emulation goes through
contained, which stops a panic there, logs it through the frontend, and marks the core poisoned. The console is kept, not dropped, because the frontend holds pointers into its memory. - Frontend memory: WRAM, the cartridge's PRG-RAM and nametable RAM are
handed to RetroArch through
SET_MEMORY_MAPS(cheats, RetroAchievements) and are withdrawn with an empty map before the console is dropped on unload, or atretro_deinitfor a frontend that skipsretro_unload_game(v2.9.2, audit AUD-16; after an unload deinit sends nothing). PRG-RAM is flagged, and exposed asRETRO_MEMORY_SAVE_RAMfor the.srm, only when the header declares a battery (v2.9.0;advanced_features.md). On a self-flashable board the.srmis the flash image,Nes::save_data, and the memory map still describes only$6000RAM (v2.9.6).
3. The Emulation Engine (rustynes-core)¶
The deterministic core. The bridge calls run_frame(); the engine runs one
frame on its single master clock, with every CPU cycle clocked in two halves
and the PPU caught up to each (ADR 0002 / ADR 0029; the pre-v2.0.0
dot-lockstep loop is retired). It returns an RGBA8 framebuffer and makes
bipolar, DC-blocked f32 audio available through drain_audio_into. It knows
nothing about RetroArch.
Testing the boundary¶
crates/rustynes-libretro/src/abi_tests.rs is a C-ABI harness: it stands in
for a frontend, calling the exported retro_* functions with fake callbacks,
and asserts on what the core hands back — loads with and without
GET_GAME_INFO_EXT, save-state sizing, memory-map withdrawal, panic
containment, logging, input polling, descriptors, the Four Score option.
Because rust-libretro has one global instance, those tests share one core
and serialize on a lock.
Thread Safety and Mutability¶
libretro calls the core from one thread. rust-libretro stores the core in a
static mut and relies on that; RustyNesLibretro mutates its state only
inside the callbacks, so no state is shared across threads.