diff --git a/.gitignore b/.gitignore index cb7acb2a5..3a10551e7 100644 --- a/.gitignore +++ b/.gitignore @@ -205,7 +205,7 @@ Desktop.ini # Gradle wrapper (gradlew[.bat] + gradle-wrapper.jar) and keystore.properties.example # are intentionally tracked. android/**/build/ -android/.gradle/ +**/.gradle/ android/local.properties android/keystore.properties *.jks diff --git a/AGENTS.md b/AGENTS.md index 6c0cf8b89..2756df6cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,51 +2,61 @@ ## Project Structure & Module Organization -This is a Rust 2021 workspace pinned to Rust 1.86 in `rust-toolchain.toml`. +This is a Rust 2024 workspace pinned to Rust 1.96 in `rust-toolchain.toml`. Workspace crates live under `crates/`: `rustynes-core` contains the emulator facade, scheduler, save states, and shared integration logic; `rustynes-cpu`, `rustynes-ppu`, `rustynes-apu`, and `rustynes-mappers` model the hardware subsystems; `rustynes-frontend` contains the `rustynes` desktop binary; `rustynes-test-harness` runs ROM-based regression tests. Project documentation is in `docs/`, planning tickets are in `to-dos/`, benchmark targets sit beside crate code in `benches/`, and licensed -test ROM fixtures are under `tests/roms/`. +test ROM fixtures are under `tests/roms/`. `ref-docs/` contains immutable hardware and emulation reference; updates go in dated supplemental files. ADRs go in `docs/adr/` (Michael Nygard format). ## Build, Test, and Development Commands - `cargo build --workspace`: build all crates. -- `cargo run --release -p rustynes-frontend -- path/to/rom.nes`: run the emulator - frontend against a local ROM. +- `cargo run --release -p rustynes-frontend -- path/to/rom.nes`: run the emulator frontend against a local ROM. - `cargo test --workspace`: run unit and integration tests. - `cargo test --workspace --features test-roms`: include ROM harness tests. +- `cargo test --workspace --features test-roms,commercial-roms`: include 60-ROM commercial oracle (needs local dumps). - `cargo clippy --workspace --all-targets -- -D warnings`: run the CI lint gate. -- `cargo fmt --all --check`: verify Rust formatting; use `cargo fmt --all` to - apply it. +- `cargo fmt --all --check`: verify Rust formatting; use `cargo fmt --all` to apply it. - `cargo bench --workspace`: run Criterion benchmarks. +- `cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features`: cross-compile to no_std. ## Coding Style & Naming Conventions Use rustfmt defaults with crate-level import grouping from `rustfmt.toml`. -`.editorconfig` requires UTF-8, LF endings, final newlines, and spaces: four for -Rust, two for Markdown/TOML/YAML. Public APIs should have rustdoc comments. -Workspace lints warn on `unsafe_code` and `missing_docs`, with Clippy -`pedantic` and `nursery` enabled; justify local `#[allow]` uses. Prefer -allocation-light code in CPU, PPU, APU, mapper, and scheduler hot paths. +`.editorconfig` requires UTF-8, LF endings, final newlines, and spaces: four for Rust, two for Markdown/TOML/YAML. +Public APIs should have rustdoc comments. Workspace lints warn on `missing_docs`, with Clippy `pedantic` and `nursery` enabled; justify local `#[allow]` uses. + +**CRITICAL DOCUMENTATION RULE:** Always craft extensive, robust, and technically detailed Rust preambles (`//!`) at the top of crates/modules and inline source code comments (`///` and `//`). Documentation must match the quantity, quality, length, and technical depth seen in existing `rustynes-*` crates, comprehensively explaining the *why* alongside architectural details, memory safety guarantees, and lockstep timing considerations. + +`unsafe` blocks require a `// SAFETY:` comment explaining the invariant. The chip stack is `#![no_std]` + `extern crate alloc;`. +No emojis in code, comments, or commits (project policy). +Hot paths (`Cpu::tick`, `Ppu::tick`, mapper register access) MUST avoid allocations, prefer fixed arrays, and be highly optimized. Target <= 2 ms/frame headless. ## Testing Guidelines Place crate-specific integration tests in `crates//tests/`. ROM-driven coverage belongs in `crates/rustynes-test-harness/tests/` with fixtures documented in `tests/roms/README.md` and `tests/roms/LICENSES.md`. Never commit commercial -ROMs; use `tests/roms/external/` for local-only material. Add focused tests for -mapper, timing, save-state, and audio changes, and run the `test-roms` feature -when touching harness code or fixtures. +ROMs; use `tests/roms/external/` for local-only material. +For accuracy work: pin the failing test ROM expectation first, then implement until it passes. The blargg / kevtris / mmc3_test_2 / AccuracyCoin suites are the closed-form definition of "cycle-accurate." ## Commit & Pull Request Guidelines Use Conventional Commits, matching the existing history: -`fix(ppu): handle OAMDATA writes during rendering` or -`docs(changelog): record release notes`. Keep subjects imperative, under 72 -characters, and without trailing periods. Before opening a PR, run fmt, clippy, -and tests; link the relevant ticket from `to-dos/`; update `CHANGELOG.md` for -user-visible behavior; and update `docs/` when architecture, mapper support, or -frontend behavior changes. +`fix(ppu): handle OAMDATA writes during rendering` or `docs(changelog): record release notes`. +Keep subjects imperative, under 72 characters, and without trailing periods. +Branch names should follow `/`. + +**CRITICAL COMMIT RULE:** Commit message bodies should ALWAYS be robust, comprehensive, and technically detailed. Go beyond a summary to explain architectural impacts, mathematical implementations, memory constraints, and deep technical specifics. + +Before opening a PR, run fmt, clippy, and tests; link the relevant ticket from `to-dos/`; update `CHANGELOG.md` under `[Unreleased]` for user-visible behavior; and update `docs/` when architecture, mapper support, or frontend behavior changes. A chip-behavior change MUST touch both the chip code and the chip's `docs/.md` to prevent drift. + +## Documentation & Markdown Guidelines + +- `docs/STATUS.md` is the authoritative single source of truth for per-suite pass counts, the mapper matrix, and version policy. +- Markdownlint is a CI gate (pre-commit, pinned `markdownlint-cli v0.39.0`). Verify with `pre-commit run markdownlint --all-files`. +- `.markdownlint.json` keeps `MD013`/`MD033`/`MD041` disabled by design. +- `rustynes-core` re-exports public types from the chip crates; downstream consumers should depend on `rustynes-core` rather than the chip crates directly. diff --git a/Cargo.lock b/Cargo.lock index f7a4b077e..a95529ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,28 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 1.0.109", + "which 4.4.2", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -575,7 +597,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -617,6 +648,17 @@ dependencies = [ "half", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -776,6 +818,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-str" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" + [[package]] name = "convert_case" version = "0.10.0" @@ -1879,6 +1927,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -2254,6 +2311,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lewton" version = "0.10.2" @@ -2399,7 +2462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" dependencies = [ "cc", - "which", + "which 8.0.4", ] [[package]] @@ -3204,6 +3267,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "peniko" version = "0.6.1" @@ -3967,6 +4036,54 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-libretro" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598635b648469ef86d1f5884d616c32540c29f8fcb45aa1c0c1eae385bccd6b2" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "const-str", + "once_cell", + "rust-libretro-proc", + "rust-libretro-sys", +] + +[[package]] +name = "rust-libretro-proc" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "195e9cfef411fb10c7e1b517b9b7b4819b759f0e36b8e7fcef66c388e53c99a1" +dependencies = [ + "proc-macro2", + "quote", + "rust-libretro-sys", + "syn 1.0.109", +] + +[[package]] +name = "rust-libretro-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbdb106ca97d195be38097412623e1e89926fa3de9f3af995145f6fa0c958c6" +dependencies = [ + "bindgen", + "libc", + "rust-libretro-sys-proc", +] + +[[package]] +name = "rust-libretro-sys-proc" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad70ae46523827ed74c292727aa40021db22397e570a31dd86240c87fc2d929" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4229,6 +4346,15 @@ dependencies = [ "wgpu", ] +[[package]] +name = "rustynes-libretro" +version = "1.9.9" +dependencies = [ + "libc", + "rust-libretro", + "rustynes-core", +] + [[package]] name = "rustynes-mappers" version = "1.9.9" @@ -4493,6 +4619,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -5997,6 +6129,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "8.0.4" diff --git a/Cargo.toml b/Cargo.toml index a8971f4ef..3b12ba811 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ "crates/rustynes-gfx-shaders", "crates/rustynes-hdpack", "crates/rustynes-ra", - "crates/rustynes-monetization", + "crates/rustynes-monetization", "crates/rustynes-libretro", ] [workspace.package] diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 07892d6d5..8b1715abc 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -133,6 +133,36 @@ pub struct Nes { } impl Nes { + /// Returns a reference to the internal WRAM. + pub fn wram(&self) -> &[u8] { + self.bus.ram.as_ref() + } + + /// Returns a mutable reference to the internal WRAM. + pub fn wram_mut(&mut self) -> &mut [u8] { + self.bus.ram.as_mut() + } + + /// Returns a reference to the cartridge SRAM (if any). + pub fn sram(&self) -> &[u8] { + self.bus.mapper.sram() + } + + /// Returns a mutable reference to the cartridge SRAM (if any). + pub fn sram_mut(&mut self) -> &mut [u8] { + self.bus.mapper.sram_mut() + } + + /// Returns a reference to the internal VRAM (nametables). + pub fn vram(&self) -> &[u8] { + self.bus.ppu.vram_ref() + } + + /// Returns a mutable reference to the internal VRAM (nametables). + pub fn vram_mut(&mut self) -> &mut [u8] { + self.bus.ppu.vram_mut() + } + /// Build a new emulator from raw ROM bytes (iNES 1.0 or NES 2.0). /// /// # Errors diff --git a/crates/rustynes-libretro/Cargo.toml b/crates/rustynes-libretro/Cargo.toml new file mode 100644 index 000000000..2cffeb1c2 --- /dev/null +++ b/crates/rustynes-libretro/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustynes-libretro" +description = "Cycle-accurate NES emulator libretro core wrapper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["nes", "emulator", "famicom", "libretro", "retroarch"] +categories = ["emulators"] + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +libc = "0.2.186" +rust-libretro = "0.3.2" + +[dependencies.rustynes-core] +path = "../rustynes-core" +default-features = false diff --git a/crates/rustynes-libretro/Makefile b/crates/rustynes-libretro/Makefile new file mode 100644 index 000000000..f8ea2b4bc --- /dev/null +++ b/crates/rustynes-libretro/Makefile @@ -0,0 +1,30 @@ +.PHONY: build release clean + +OS := $(shell uname -s) +ifeq ($(OS),Darwin) + EXT := dylib + PREFIX := lib +else ifeq ($(OS),Linux) + EXT := so + PREFIX := lib +else + EXT := dll + PREFIX := +endif + +# Target directory is relative to the workspace root +TARGET_DIR := ../../target + +build: + cargo build -p rustynes-libretro + cp $(TARGET_DIR)/debug/$(PREFIX)rustynes_libretro.$(EXT) rustynes_libretro.$(EXT) + @echo "Copied debug core to rustynes_libretro.$(EXT)" + +release: + cargo build --release -p rustynes-libretro + cp $(TARGET_DIR)/release/$(PREFIX)rustynes_libretro.$(EXT) rustynes_libretro.$(EXT) + @echo "Copied release core to rustynes_libretro.$(EXT)" + +clean: + cargo clean -p rustynes-libretro + rm -f rustynes_libretro.so rustynes_libretro.dylib rustynes_libretro.dll diff --git a/crates/rustynes-libretro/expanded.rs b/crates/rustynes-libretro/expanded.rs new file mode 100644 index 000000000..e69de29bb diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info new file mode 100644 index 000000000..c3768bfcb --- /dev/null +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -0,0 +1,38 @@ +# Software Information +display_name = "Nintendo - NES / Famicom (RustyNES)" +authors = "DoubleGate" +supported_extensions = "nes|fds" +corename = "RustyNES" +license = "MIT OR Apache-2.0" +permissions = "" +display_version = "v1.0.0" +categories = "Emulator" + +# Hardware Information +manufacturer = "Nintendo" +systemname = "Nintendo Entertainment System" +systemid = "nes" + +# Libretro Features +supports_no_game = "false" +savestate = "true" +savestate_features = "deterministic" +cheats = "true" +input_descriptors = "true" +memory_descriptors = "true" +libretro_saves = "true" +core_options = "false" +load_subsystem = "false" +hw_render = "false" +needs_fullpath = "false" +disk_control = "false" +database = "Nintendo - Nintendo Entertainment System|Nintendo - Family Computer Disk System" + +# Firmware / BIOS +firmware_count = 1 +firmware0_desc = "disksys.rom (Family Computer Disk System BIOS)" +firmware0_path = "disksys.rom" +firmware0_opt = "true" +notes = "(!) disksys.rom (md5): ca30b50f880eb660a320674ed365ef7a|RustyNES requires this BIOS in the system directory to boot Famicom Disk System games." + +description = "RustyNES is a cycle-accurate Nintendo Entertainment System emulator written entirely in safe Rust. Targeting the higan and Mesen2 accuracy tier, it relies on a strict, lockstep master-clock scheduler at PPU-dot resolution rather than per-game hacks. The core guarantees absolute determinism, making it an exceptional choice for advanced libretro features like run-ahead latency reduction, rollback netplay, and RetroAchievements. With high compatibility spanning over 168 mapper families, full Famicom Disk System support, and true RGB Vs. System arcade boards, RustyNES is ideal for users who seek reference-grade accuracy paired with modern stability." diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs new file mode 100644 index 000000000..4b7cfb7c9 --- /dev/null +++ b/crates/rustynes-libretro/src/lib.rs @@ -0,0 +1,377 @@ +//! RustyNES Libretro Core. +//! +//! This crate implements the C ABI boundary for the `rustynes-core` engine, exposing +//! the standard libretro lifecycle hooks (`retro_init`, `retro_load_game`, `retro_run`, etc.) +//! required by RetroArch and other compatible frontends. +//! +//! # Architecture +//! +//! The Libretro wrapper operates as a thin, safe facade over the `Nes` emulator struct. +//! Because the emulator guarantees strict cycle-accuracy (a lockstep master clock for the +//! CPU/PPU/APU) and strict determinism, this crate avoids mutating emulation flow. +//! +//! - **Video**: Native 256x240 framebuffers are handed off directly to `VideoContext`. +//! - **Audio**: Audio is drained per frame and interleaved (left/right) into a pooled +//! buffer before pushing via `batch_audio_samples`. The accumulator relies on a +//! pre-allocated array (or sufficient `Vec` capacity) to honor the hot-path allocation bans. +//! - **Input**: The Joypad API is polled each frame and bitmasked into `rustynes_core::Buttons`. +//! - **Save States & Memory Maps**: Direct pointers to WRAM, SRAM, and VRAM are provided +//! safely by isolating the memory accessors in the core. Save states serialize statically +//! sized binary blobs natively through `Nes::snapshot_core_into`. + +// We allow `unsafe_code` globally for this crate because it is an FFI boundary +// wrapper (like `rustynes-frontend` and `rustynes-cheevos`). All raw pointers +// originate from or are delivered to the frontend environment. +#![allow(unsafe_code)] +#![allow(clippy::wildcard_imports)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::ptr_as_ptr)] +#![warn(missing_docs)] + +use rust_libretro::{ + contexts::*, + core::{Core, CoreOptions}, + retro_core, + sys::*, + types::*, +}; +use rustynes_core::Nes; +use std::ffi::CString; + +/// The central libretro core structure for RustyNES. +/// +/// This struct holds the underlying cycle-accurate `Nes` emulator instance alongside +/// the operational buffers necessary to interface with libretro's batch APIs. It is +/// statically instantiated via the `retro_core!` macro. +pub struct RustyNesLibretro { + /// The cycle-accurate RustyNES core instance. + /// + /// Exists as an `Option` because `retro_init` fires before `retro_load_game`. + nes: Option, + + /// Intermediate buffer for left/right interleaved audio samples. + /// + /// Pre-allocated with capacity to hold multiple frames of audio, ensuring + /// the hot `on_run` loop avoids any heap allocations. + audio_buffer: Vec, + + /// Intermediate buffer for raw floating-point audio samples from the core. + /// + /// Pre-allocated to avoid heap allocations when draining audio batches. + audio_float_buffer: Vec, + + /// Intermediate buffer for the video framebuffer. + /// + /// Pre-allocated to hold 256x240 RGBA8 pixels. Used to swap R and B channels + /// to match the XRGB8888 libretro pixel format. + video_buffer: Vec, + + /// Pre-computed save state size (constant for a given ROM and mapper). + /// + /// Stored upon ROM loading to satisfy libretro's `get_serialize_size` contract, + /// guaranteeing the frontend allocates a precisely sized buffer. + serialize_size: usize, + + /// Pre-allocated buffer for snapshot serialization. + serialize_buffer: Vec, +} + +impl Default for RustyNesLibretro { + fn default() -> Self { + Self { + nes: None, + // 4096 samples comfortably holds ~85ms of audio at 48kHz, + // well beyond the 16.6ms standard 60Hz frame delivery. + audio_buffer: Vec::with_capacity(4096), + audio_float_buffer: Vec::with_capacity(4096), + video_buffer: Vec::with_capacity(256 * 240 * 4), + serialize_size: 0, + serialize_buffer: Vec::new(), + } + } +} + +impl CoreOptions for RustyNesLibretro {} + +#[repr(C)] +struct RetroGameInfoExt { + full_path: *const std::os::raw::c_char, + archive_path: *const std::os::raw::c_char, + archive_file: *const std::os::raw::c_char, + dir: *const std::os::raw::c_char, + name: *const std::os::raw::c_char, + ext: *const std::os::raw::c_char, + meta_data: *const std::os::raw::c_char, + data: *const std::os::raw::c_void, + size: usize, + file_in_archive: bool, + persistent_data: bool, +} + +impl Core for RustyNesLibretro { + fn get_info(&self) -> SystemInfo { + SystemInfo { + library_name: CString::new("RustyNES").unwrap(), + library_version: CString::new(env!("CARGO_PKG_VERSION")).unwrap(), + valid_extensions: CString::new("nes|fds").unwrap(), + need_fullpath: false, + block_extract: false, + } + } + + fn on_get_av_info(&mut self, _ctx: &mut GetAvInfoContext) -> retro_system_av_info { + // Return standard NTSC geometries. The core runs internally at ~60.0988 FPS + // for NTSC standard. We match the internal audio mixing rate of 44.1kHz. + retro_system_av_info { + geometry: retro_game_geometry { + base_width: 256, + base_height: 240, + max_width: 256, + max_height: 240, + aspect_ratio: 0.0, + }, + timing: retro_system_timing { + fps: 60.0988, + sample_rate: 44100.0, + }, + } + } + + fn on_set_environment(&mut self, _initial: bool, ctx: &mut SetEnvironmentContext) { + // SAFETY: The Libretro API guarantees that the context provides a valid environment + // callback pointer. `set_pixel_format` and `set_input_descriptors` invoke safe FFI + // abstractions over that valid pointer. + unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + let cb = *generic_ctx.environment_callback(); + + // XRGB8888 is preferred by RustyNES because it maps well to standard 32-bit GPU textures. + if !rust_libretro::environment::set_pixel_format(cb, PixelFormat::XRGB8888) { + eprintln!( + "[RustyNES] Error: Frontend rejected XRGB8888 pixel format. Colors will be broken." + ); + } + + // Register standardized controller layouts for the frontend to bind against. + let descriptors = rust_libretro::input_descriptors!( + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A, "NES A Button" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B, "NES B Button" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_SELECT, "Select" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_START, "Start" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_UP, "D-Pad Up" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_DOWN, "D-Pad Down" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_LEFT, "D-Pad Left" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_RIGHT, "D-Pad Right" } + ); + rust_libretro::environment::set_input_descriptors(cb, &descriptors); + } + } + + fn on_load_game( + &mut self, + _game: Option, + ctx: &mut LoadGameContext, + ) -> Result<(), Box> { + // We use `GET_GAME_INFO_EXT` directly via the raw environment callback. + let ext_info = unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + let cb = generic_ctx.environment_callback().unwrap(); + let mut ptr: *const RetroGameInfoExt = std::ptr::null(); + + // SAFETY: `cb` is a valid function pointer supplied by the libretro frontend + // via the environment context. If the callback returns true, the spec guarantees + // `ptr` is set to a valid, aligned, frontend-owned `RetroGameInfoExt` whose + // lifetime is at least as long as this `on_load_game` invocation. + // `as_ref()` returns `None` if `ptr` remains null (a spec-violating frontend + // that returns `true` without setting the pointer), ensuring we never produce + // a reference from an invalid address. + if cb( + rust_libretro::sys::RETRO_ENVIRONMENT_GET_GAME_INFO_EXT, + std::ptr::addr_of_mut!(ptr).cast::(), + ) { + ptr.as_ref() + } else { + None + } + } + .ok_or("Frontend does not support get_game_info_ext")?; + + let rom_data = if ext_info.data.is_null() { + return Err("ext_info data pointer is NULL. The frontend did not load the ROM into memory (need_fullpath is false).".into()); + } else { + eprintln!("[RustyNES] ext_info data is valid. Size: {}", ext_info.size); + // SAFETY: `data` is non-null (checked above). The libretro spec guarantees + // the pointer references a valid, contiguous byte slice of exactly `size` + // bytes, owned by the frontend for the duration of this call. + let slice = + unsafe { std::slice::from_raw_parts(ext_info.data.cast::(), ext_info.size) }; + slice.to_vec() + }; + + let nes = match Nes::from_rom(&rom_data) { + Ok(n) => n, + Err(e) => { + eprintln!("[RustyNES] Failed to parse ROM: {e:?}"); + return Err(format!("Failed to load ROM: {e:?}").into()); + } + }; + + // Save state sizes in RustyNES are strictly deterministic for a given ROM image. + // We evaluate the snapshot footprint once during initialization to satisfy + // libretro's serialization size querying contract. + let mut tmp = Vec::new(); + nes.snapshot_core_into(&mut tmp); + self.serialize_size = tmp.len(); + + self.nes = Some(nes); + Ok(()) + } + + fn on_run(&mut self, ctx: &mut RunContext, _delta_us: Option) { + if let Some(nes) = self.nes.as_mut() { + ctx.poll_input(); + + // Map the Libretro joypad state into the native RustyNES controller bitmasks. + // Port 0 maps to Player 1, Port 1 maps to Player 2. + let mut apply_input = |port: usize| { + let jp = ctx.get_joypad_state(port as u32, 0); + let mut bt = rustynes_core::Buttons::empty(); + if jp.contains(JoypadState::A) { + bt |= rustynes_core::Buttons::A; + } + if jp.contains(JoypadState::B) { + bt |= rustynes_core::Buttons::B; + } + if jp.contains(JoypadState::SELECT) { + bt |= rustynes_core::Buttons::SELECT; + } + if jp.contains(JoypadState::START) { + bt |= rustynes_core::Buttons::START; + } + if jp.contains(JoypadState::UP) { + bt |= rustynes_core::Buttons::UP; + } + if jp.contains(JoypadState::DOWN) { + bt |= rustynes_core::Buttons::DOWN; + } + if jp.contains(JoypadState::LEFT) { + bt |= rustynes_core::Buttons::LEFT; + } + if jp.contains(JoypadState::RIGHT) { + bt |= rustynes_core::Buttons::RIGHT; + } + nes.set_buttons(port, bt); + }; + + apply_input(0); + apply_input(1); + + // Advance the emulator clock by precisely one frame (PPU dots). + // This is the core lockstep routine triggering CPU/APU progression. + let framebuffer = nes.run_frame(); + + self.video_buffer.clear(); + self.video_buffer.extend_from_slice(framebuffer); + for chunk in self.video_buffer.chunks_exact_mut(4) { + chunk.swap(0, 2); // Swap R and B to convert RGBA8 to XRGB8888 (in-memory B G R X) + } + + // The generated framebuffer is exactly 256x240 and formatted as XRGB8888. + ctx.draw_frame(&self.video_buffer, 256, 240, 256 * 4); + + // Drain synthesized audio. RustyNES produces `f32` floats which we scale + // to the standard signed 16-bit integer expected by the frontend. + // The audio buffers are pre-allocated; draining and converting will not + // trigger heap allocations on this critical hot path. + self.audio_buffer.clear(); + self.audio_float_buffer.resize(4096, 0.0); + let produced = nes.drain_audio_into(&mut self.audio_float_buffer); + for &sample in &self.audio_float_buffer[..produced] { + // RustyNES APU outputs bipolar ~[-0.5, 0.5], so we scale by 65535.0 + let s16 = (sample * 65535.0).clamp(-32768.0, 32767.0) as i16; + // Duplicate the sample for stereo interleaving (Left, Right) + self.audio_buffer.push(s16); + self.audio_buffer.push(s16); + } + + rust_libretro::contexts::AudioContext::from(&mut *ctx) + .batch_audio_samples(&self.audio_buffer); + } + } + + fn get_memory_data( + &mut self, + id: std::os::raw::c_uint, + _ctx: &mut GetMemoryDataContext, + ) -> *mut std::os::raw::c_void { + // Expose zero-cost direct memory maps for RetroAchievements and cheat engines. + // Memory boundary enforcement remains safe within the `rustynes_core` design. + self.nes + .as_mut() + .map_or(std::ptr::null_mut(), |nes| match id { + RETRO_MEMORY_SAVE_RAM => { + let sram = nes.sram_mut(); + if sram.is_empty() { + std::ptr::null_mut() + } else { + sram.as_mut_ptr().cast::() + } + } + RETRO_MEMORY_SYSTEM_RAM => { + nes.wram_mut().as_mut_ptr().cast::() + } + RETRO_MEMORY_VIDEO_RAM => { + nes.vram_mut().as_mut_ptr().cast::() + } + _ => std::ptr::null_mut(), + }) + } + + fn get_memory_size( + &mut self, + id: std::os::raw::c_uint, + _ctx: &mut GetMemorySizeContext, + ) -> usize { + self.nes.as_ref().map_or(0, |nes| match id { + RETRO_MEMORY_SAVE_RAM => nes.sram().len(), + RETRO_MEMORY_SYSTEM_RAM => nes.wram().len(), + RETRO_MEMORY_VIDEO_RAM => nes.vram().len(), + _ => 0, + }) + } + + fn get_serialize_size(&mut self, _ctx: &mut GetSerializeSizeContext) -> usize { + self.serialize_size + } + + fn on_serialize(&mut self, slice: &mut [u8], _ctx: &mut SerializeContext) -> bool { + // Generates the deterministic binary blob representing the console hardware state. + if let Some(nes) = self.nes.as_ref() { + self.serialize_buffer.clear(); + nes.snapshot_core_into(&mut self.serialize_buffer); + if slice.len() >= self.serialize_buffer.len() { + slice[..self.serialize_buffer.len()].copy_from_slice(&self.serialize_buffer); + return true; + } + } + false + } + + fn on_unserialize(&mut self, slice: &mut [u8], _ctx: &mut UnserializeContext) -> bool { + // Restores the cycle-accurate lockstep hardware state from the serialized blob. + if let Some(nes) = self.nes.as_mut() { + return nes.restore_quiet(slice).is_ok(); + } + false + } +} + +retro_core!(RustyNesLibretro { + nes: None, + audio_buffer: Vec::with_capacity(4096), + audio_float_buffer: Vec::with_capacity(4096), + video_buffer: Vec::with_capacity(256 * 240 * 4), + serialize_size: 0, + serialize_buffer: Vec::new(), +}); diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index 0e5a66b70..377df7b2a 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -1822,6 +1822,12 @@ impl Fds { const FDS_SAVE_VERSION: u8 = 3; impl Mapper for Fds { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio // (the audio hook only exists under the `mapper-audio` feature). fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/jaleco_ss88006.rs b/crates/rustynes-mappers/src/jaleco_ss88006.rs index fa31efb6b..64b13373d 100644 --- a/crates/rustynes-mappers/src/jaleco_ss88006.rs +++ b/crates/rustynes-mappers/src/jaleco_ss88006.rs @@ -219,6 +219,12 @@ impl JalecoSs88006 { } impl Mapper for JalecoSs88006 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 9eb9fb8a3..daa6c35fa 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -441,6 +441,18 @@ pub trait Mapper: Send { /// non-FDS mappers; an out-of-range index is ignored by the FDS device. fn set_disk_side(&mut self, _side: Option) {} + /// Returns a reference to the mapper's internal SRAM/PRG-RAM. + /// By default, returns an empty slice if unsupported. + fn sram(&self) -> &[u8] { + &[] + } + + /// Returns a mutable reference to the mapper's internal SRAM/PRG-RAM. + /// By default, returns an empty mutable slice if unsupported. + fn sram_mut(&mut self) -> &mut [u8] { + &mut [] + } + /// Start recording the diagnostic FDS read-stream trace (off by default; /// observation-only). No-op for non-FDS mappers. See [`crate::FdsTraceRec`]. fn enable_fds_trace(&mut self) {} diff --git a/crates/rustynes-mappers/src/mmc1.rs b/crates/rustynes-mappers/src/mmc1.rs index a3ba9587c..7c92503aa 100644 --- a/crates/rustynes-mappers/src/mmc1.rs +++ b/crates/rustynes-mappers/src/mmc1.rs @@ -229,6 +229,12 @@ impl Mmc1 { } impl Mapper for Mmc1 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — MMC1 overrides ONLY notify_cpu_cycle (the // consecutive-write throttle); it has no IRQ and no audio. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/mmc3.rs b/crates/rustynes-mappers/src/mmc3.rs index 4e6dad36f..e03fc6f8c 100644 --- a/crates/rustynes-mappers/src/mmc3.rs +++ b/crates/rustynes-mappers/src/mmc3.rs @@ -404,6 +404,12 @@ impl Mmc3 { } impl Mapper for Mmc3 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ diff --git a/crates/rustynes-mappers/src/mmc5.rs b/crates/rustynes-mappers/src/mmc5.rs index 4d40ddb37..8efc31d41 100644 --- a/crates/rustynes-mappers/src/mmc5.rs +++ b/crates/rustynes-mappers/src/mmc5.rs @@ -862,6 +862,12 @@ impl Mmc5 { } impl Mapper for Mmc5 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — MMC5: CPU-cycle hook + IRQ + frame-counter- // cadenced audio envelopes (+ expansion audio under `mapper-audio`). fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/namco175.rs b/crates/rustynes-mappers/src/namco175.rs index 7a46944ed..c5fedfbe6 100644 --- a/crates/rustynes-mappers/src/namco175.rs +++ b/crates/rustynes-mappers/src/namco175.rs @@ -150,6 +150,12 @@ impl Namco175 { } impl Mapper for Namco175 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/nrom.rs b/crates/rustynes-mappers/src/nrom.rs index d280962ca..699ae55aa 100644 --- a/crates/rustynes-mappers/src/nrom.rs +++ b/crates/rustynes-mappers/src/nrom.rs @@ -122,6 +122,12 @@ impl Nrom { } impl Mapper for Nrom { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/sprint10.rs b/crates/rustynes-mappers/src/sprint10.rs index 8a970ad09..db837f4c4 100644 --- a/crates/rustynes-mappers/src/sprint10.rs +++ b/crates/rustynes-mappers/src/sprint10.rs @@ -1170,6 +1170,12 @@ impl WaixingFs304M162 { } impl Mapper for WaixingFs304M162 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } fn caps(&self) -> MapperCaps { MapperCaps::NONE } @@ -1338,6 +1344,12 @@ impl Waixing178 { } impl Mapper for Waixing178 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } fn caps(&self) -> MapperCaps { MapperCaps::NONE } diff --git a/crates/rustynes-mappers/src/sprint2.rs b/crates/rustynes-mappers/src/sprint2.rs index e2f21aded..c8f71cb22 100644 --- a/crates/rustynes-mappers/src/sprint2.rs +++ b/crates/rustynes-mappers/src/sprint2.rs @@ -397,6 +397,12 @@ impl Mmc4 { } impl Mapper for Mmc4 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { @@ -861,6 +867,12 @@ impl M34 { } impl Mapper for M34 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/sprint3.rs b/crates/rustynes-mappers/src/sprint3.rs index 4c7930a9f..458c28e2d 100644 --- a/crates/rustynes-mappers/src/sprint3.rs +++ b/crates/rustynes-mappers/src/sprint3.rs @@ -205,6 +205,12 @@ impl Vrc2 { } impl Mapper for Vrc2 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { @@ -477,6 +483,12 @@ impl Vrc4 { } impl Mapper for Vrc4 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ @@ -997,6 +1009,12 @@ impl Vrc6 { } impl Mapper for Vrc6 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio // (the audio hook only exists under the `mapper-audio` feature). fn caps(&self) -> MapperCaps { @@ -1606,6 +1624,12 @@ impl Vrc7 { } impl Mapper for Vrc7 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio // (the audio hook only exists under the `mapper-audio` feature). fn caps(&self) -> MapperCaps { @@ -2517,6 +2541,12 @@ impl Fme7 { } impl Mapper for Fme7 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio // (the audio hook only exists under the `mapper-audio` feature). fn caps(&self) -> MapperCaps { @@ -3208,6 +3238,12 @@ impl Namco163 { } impl Mapper for Namco163 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio // (the audio hook only exists under the `mapper-audio` feature). fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/sprint9.rs b/crates/rustynes-mappers/src/sprint9.rs index fef7245ea..dd6ffbf10 100644 --- a/crates/rustynes-mappers/src/sprint9.rs +++ b/crates/rustynes-mappers/src/sprint9.rs @@ -1973,6 +1973,12 @@ impl Waixing242 { } impl Mapper for Waixing242 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } fn caps(&self) -> MapperCaps { MapperCaps::NONE } @@ -2164,6 +2170,12 @@ impl FongShenBang246 { } impl Mapper for FongShenBang246 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } fn caps(&self) -> MapperCaps { MapperCaps::NONE } diff --git a/crates/rustynes-mappers/src/sunsoft4.rs b/crates/rustynes-mappers/src/sunsoft4.rs index d03136e44..110fc420c 100644 --- a/crates/rustynes-mappers/src/sunsoft4.rs +++ b/crates/rustynes-mappers/src/sunsoft4.rs @@ -170,6 +170,12 @@ impl Sunsoft4 { } impl Mapper for Sunsoft4 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/taito_x1_017.rs b/crates/rustynes-mappers/src/taito_x1_017.rs index 94955e9d1..853e5485d 100644 --- a/crates/rustynes-mappers/src/taito_x1_017.rs +++ b/crates/rustynes-mappers/src/taito_x1_017.rs @@ -186,6 +186,12 @@ impl TaitoX1017 { } impl Mapper for TaitoX1017 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/vrc3.rs b/crates/rustynes-mappers/src/vrc3.rs index 5b2412963..200d5ec24 100644 --- a/crates/rustynes-mappers/src/vrc3.rs +++ b/crates/rustynes-mappers/src/vrc3.rs @@ -103,6 +103,12 @@ impl Vrc3 { } impl Mapper for Vrc3 { + fn sram(&self) -> &[u8] { + &self.prg_ram + } + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.prg_ram + } // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source; no on-cart audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 00f0b6df2..1c5942b12 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -970,7 +970,17 @@ impl Ppu { } } - /// Reset (warm boot). Per `docs/ppu-2c02.md`: + /// Returns a reference to the internal CIRAM (nametables). + pub fn vram_ref(&self) -> &[u8] { + &self.ciram + } + + /// Returns a mutable reference to the internal CIRAM (nametables). + pub fn vram_mut(&mut self) -> &mut [u8] { + &mut self.ciram + } + + /// Performs a soft-reset of the PPU (warm boot). Per `docs/ppu-2c02.md`: /// - PPUCTRL := 0 /// - PPUMASK := 0 /// - w toggle := 0 diff --git a/docs/hd-pack-zelda-troubleshooting.md b/docs/hd-pack-zelda-troubleshooting.md new file mode 100644 index 000000000..74ade60c4 --- /dev/null +++ b/docs/hd-pack-zelda-troubleshooting.md @@ -0,0 +1,244 @@ +# HD-Pack troubleshooting — *The Legend of Zelda* (working doc) + +A living scratchpad for diagnosing and fixing the remaining HD-pack rendering +issues seen with a *Legend of Zelda* (USA) Mesen HD-pack. It records the +symptoms, the architecture, what has already been ruled out (with code +evidence), the open hypotheses, and the exact diagnostic procedure so a future +session can resume without re-deriving everything. + +> **Status:** OPEN. Sprites render correctly; backgrounds are wrong. Root cause +> not yet confirmed — it needs an in-app **HD-Pack Pixel Inspector** reading +> and/or a Mesen2 cross-check (see [Diagnostic procedure](#diagnostic-procedure)); +> any fix must preserve byte-identity (see [Constraints](#constraints-on-any-fix)). +> Last updated 2026-06-24. + +## How to reproduce + +- Build the maximal native binary (HD-pack + debug hooks on): + `cargo full-run "/path/to/Legend of Zelda.nes"` + (`full` aggregates `hd-pack` + `debug-hooks` + the rest; see the project + CLAUDE.md build section.) +- Load the Mesen Zelda HD-pack (the folder/zip with `hires.txt`) the usual way. +- Let the title auto-load and watch the scrolling story text, then start a game / + enter the old-man cave. + +Zelda (USA) is **MMC1 (mapper 1)** with **8 KiB CHR-RAM** (no CHR-ROM), so +*every* tile — background and sprite — is matched by the CHR-RAM content path +(`CalculateHash(palette ++ 16 CHR bytes)`), not the CHR-ROM index path. That is +important: any bug specific to the **content/palette key for backgrounds** would +break BG tiles while leaving sprites fine, which is exactly the observed split. + +## Symptoms (observed) + +1. **Title scrolling text — partial glyphs.** After the auto-load, the scrolling + story text is only partially rendered. Example: the long vertical stem of an + "F" is cut off. The text scrolls **vertically** (fine-Y scroll), so a screen + cell straddles two vertical nametable tiles. +2. **In-game / cave — black backgrounds.** Starting a game, or inside the + old-man cave, the backgrounds render **black with nothing drawn**, even though + the **sprites are placed correctly** (sword, the old man, etc.). + +So: **sprite replacement works; background replacement does not.** Backgrounds +are either not matching their HD replacements or are being blanked. + +## Architecture recap (where each piece lives) + +The HD-pack render path is **output-only** and `hd-pack`-gated; it never touches +the deterministic core (AccuracyCoin / byte-identity must hold — see +[Constraints](#constraints-on-any-fix)). Data flow per frame: + +1. **PPU telemetry (`crates/rustynes-ppu/src/ppu.rs`).** While rendering, the PPU + fills a parallel `HdTileSource` record per pixel (256×240): the CHR tile base + address, bg/sprite flag, flips, the packed Mesen `PaletteColors`, the absolute + CHR-ROM tile index (or a CHR-RAM sentinel), per-pixel `offset_x`/`offset_y`, + `color_mask`, and the covering sprite list. Output-only. +2. **Per-frame snapshots (frontend).** At produce time, under the emu lock, the + frontend snapshots (a) the framebuffer, (b) the 8 KiB CHR (`$0000..=$1FFF`), + (c) the watched-memory addresses for conditions. These feed the lock-free + compositor (`docs/adr/0014`). +3. **Compositor (`crates/rustynes-hdpack/src/hdpack.rs`).** Upscales the base + framebuffer, draws `` regions, then for each pixel computes the + tile key, looks up a `` replacement (exact key → wildcard key), checks + its ``s, and blits the matched texel at the pixel's intra-tile + offset. `disableOriginalTiles` hides the stock tile under a backdrop. + +Key matching functions (`hdpack.rs`): + +- `chr_ram_key(palette_colors, &content[16]) = calculate_hash(palette ++ content)` + — the CHR-RAM (content) key. **This is the path Zelda uses.** +- `chr_rom_key(tile_index, palette_colors) = tile_index ^ palette_colors` + — the CHR-ROM (index) key. Not used by Zelda. +- Lookup is two-stage: exact key (palette-discriminated), then the wildcard key + (`palette = 0xFFFFFFFF`, the `defaultTile=Y` form). + +## What is already confirmed working + +These landed earlier in the HD-pack parity work and are believed correct: + +- CHR-RAM content matching (`CalculateHash`) — commit `20fc28c`. +- Per-pixel `offset_x`/`offset_y` positioning + Mesen `usePrev` fine-X tile + selection — commit `63ad021`. +- Run-ahead snapshot timing (CHR snapshot taken from the visible frame) — + commit `38c60d4`. +- CHR-ROM index matching — commit `f9692dc`. +- `disableOriginalTiles` — commit `afa143e` (suspected to *expose* the + bug by blanking unmatched BG; see open hypotheses). + +The whole P1–P3 + `` + tileData-hash Mesen2 parity set is implemented +(see `docs/adr/0014`), and **sprites match correctly in this very pack**, which +proves the sprite key path + the snapshot + the blit all work end-to-end. + +## Hypotheses RULED OUT (with code evidence) + +Each was checked in code this session and does **not** explain the symptoms: + +| # | Hypothesis | Evidence it is NOT the cause | +|---|---|---| +| 1 | BG `chr_addr` includes fine-Y, so the content hash reads 16 bytes from the wrong offset for scrolled BG | `ppu.rs` BG fetch: `self.hd_bg_addr_latch = addr & 0x1FF0;` — the low nibble (fine-Y) **is masked**, so the captured address is the tile base. | +| 2 | BG palette packed wrong (so BG never matches but sprites do) | `Ppu::hd_bg_palette_colors` packs the four palette bytes at `base = $3F00 + group*4` (with `pr[0]` the universal backdrop in the high byte) into Mesen's `PaletteColors`; sprites use the `0xFF000000`-tagged form with no `pr[0]`. Both Mesen-faithful. | +| 3 | grayscale/emphasis `color_mask` blacks out the BG replacement | `apply_color_mask` returns `rgb` unchanged when `mask==0`, and otherwise only averages (grayscale) or scales channels by 0.9–1.1 (emphasis). It **never** zeroes a pixel. | +| 4 | CHR snapshot covers only one 4 KiB pattern table, so BG (other table) reads garbage | The snapshot is `$0000..=$1FFF` (`0x2000` = 8 KiB, both tables). `app.rs` resizes/zero-fills to `0x2000`; `emu.rs::capture_hd_chr` fills it. | +| 5 | The per-pixel blit drops the bottom rows (off-by-one) | The composite blit samples `src_ty = rule.y + offset_y*scale` with `if sy >= img_h … continue`; for an 8×8·scale tile region this stays in-bounds for `offset_y ∈ 0..=7`. No systematic bottom cutoff. | +| 6 | The vertical tile boundary isn't tracked (cell straddles two nametable tiles) | `HdTileSource` is **per-pixel**, and the PPU re-fetches per scanline, so each pixel's `chr_addr` is the nametable tile for *that* scanline and `offset_y` is that scanline's fine-Y. The split is handled pixel-for-pixel. | + +## Open hypotheses (ranked) + +Ordered by suspicion, given "sprites fine, BG black, title text partial": + +1. **BG content/palette key mismatch (most likely).** The pack's BG `` + rules are keyed under a palette (or exact content) that differs from what the + game produces at that moment, so the **exact** key misses and (if the rule + isn't a `defaultTile=Y` wildcard) the **wildcard** key misses too → no match. + The partial title glyphs fit this: some font tiles' keys align, others don't. +2. **`disableOriginalTiles` exposing the gap.** If the pack sets + `disableOriginalTiles`, every unmatched BG tile is replaced by the + backdrop (black in Zelda dungeons). Before that option landed (`afa143e`) the + unmatched BG showed the *original* NES graphics; now it shows black. This is + Mesen-faithful behaviour, so it is an **amplifier**, not necessarily the bug — + the underlying cause is still hypothesis 1. +3. **The pack genuinely lacks dungeon BG art.** Some packs only replace sprites + + title. If so, black-under-`disableOriginalTiles` is *correct* and matches + Mesen. The Mesen cross-check below settles this. +4. **Snapshot/run-ahead timing for content that changes per frame.** Lower + suspicion for Zelda (the alphabet in CHR-RAM is static during the title + scroll), but worth confirming if run-ahead is enabled. +5. **A real blit bug specific to a sampling state.** Only if the inspector says a + tile is *APPLIED* yet visibly isn't drawn. + +## Diagnostic procedure + +Use the in-app **HD-Pack Pixel Inspector** +(`crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs`; needs `hd-pack` + +`debug-hooks`, both in the `full` build). Hover a pixel and it prints: + +```text +Pixel (x, y) +base / final swatches +tile CHR $XXXX bg | sprite pal N +flip H _ flip V _ (sprites only) +CHR hash XXXXXXXX +→ exactly one verdict: + "Replacement APPLIED (image #N)." + "No replacement rule keys this tile hash." + "Rule for image #N gated off (a condition failed)." + + a Conditions list with [hold] / [fail] per condition +``` + +Collect readings for **three** spots and record them in +[Captured readings](#captured-readings): + +1. A **black in-game / cave BG tile** (a wall that should be HD). +2. The **rendered part** of a title-screen "F". +3. The **cut-off part** of that same "F". + +Also do the decisive cross-check: **load the identical pack in Mesen2.** Does the +dungeon BG render in Mesen? + +## Decision tree (reading → cause → fix area) + +- Wall says **"No replacement rule keys this tile hash"** *and* Mesen renders it → + **BG key mismatch (hypothesis 1).** Compare the inspector's `pal N` + `CHR hash` + to the pack's `` lines for that art. Likely a palette-discrimination + issue: confirm whether the pack keys BG tiles as `defaultTile=Y` (wildcard) or + per-palette, and whether `hd_bg_palette_colors` reproduces the exact palette the + pack assumed. Fix in the key computation (`ppu.rs` palette pack and/or + `hdpack.rs` key/lookup). +- Wall says **"No replacement rule keys this tile hash"** *and* Mesen is **also** + black there → the **pack lacks dungeon BG** (not a RustyNES bug). Close as + "pack limitation"; optionally document that `disableOriginalTiles` makes it + black by design. +- Wall says **"Rule … gated off"** → a `` is failing. Read the + `[fail]` condition name and check that predicate's eval against the snapshot in + `hdpack.rs::eval_condition`. +- The F's cut-off part says **"APPLIED" but isn't visibly drawn** → a real **blit + bug**; capture `pal`, `CHR hash`, `offset_x/offset_y`, the image # and the + output (x,y), and inspect the composite blit (`hdpack.rs`, the per-pixel tile + loop) for that offset. +- The F's cut-off part says **"No replacement rule keys this tile hash"** while + the rendered part says **APPLIED** → two different font tiles; the boundary tile + isn't in the pack (hypothesis 1 again, scoped to specific glyph tiles). + +## Code reference map + +| Concern | Location | +|---|---| +| BG tile-address capture (fine-Y masked) | `crates/rustynes-ppu/src/ppu.rs` — `hd_bg_addr_latch = addr & 0x1FF0` | +| BG / sprite palette-key packing | `crates/rustynes-ppu/src/ppu.rs` — `hd_bg_palette_colors`, `hd_sprite_palette_colors` | +| Per-pixel emit (offset_x/offset_y, cur/next, sprite list) | `crates/rustynes-ppu/src/ppu.rs` — the `hd_tile_source` emit block | +| Key functions + two-stage lookup | `crates/rustynes-hdpack/src/hdpack.rs` — `chr_ram_key`, `chr_rom_key`, `calculate_hash` | +| Compositor + per-pixel blit + `disableOriginalTiles` | `crates/rustynes-hdpack/src/hdpack.rs` — `composite` | +| grayscale/emphasis mask | `crates/rustynes-hdpack/src/hdpack.rs` — `apply_color_mask` | +| Condition eval | `crates/rustynes-hdpack/src/hdpack.rs` — `eval_condition` / `all_hold` | +| CHR snapshot (8 KiB) | `crates/rustynes-frontend/src/emu.rs` — `capture_hd_chr`; `crates/rustynes-frontend/src/app.rs` — `present_chr_snapshot` | +| Pixel Inspector panel | `crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs` | +| HD-pack spec / parity status | `docs/adr/0014-hd-pack-conditions-and-backgrounds.md`, ADR 0018 (real Mesen tile format), `docs/ppu-2c02.md` (HD-pack tile-source export) | +| Mesen2 reference | `ref-proj/Mesen2/Core/NES/HdPacks/` | + +## Captured readings + +Fill these in during the next session (replace the placeholders): + +```text +[1] Black cave/dungeon BG wall tile: + tile CHR $____ bg pal __ + CHR hash ________ + verdict: ____________________________________ + conditions: _________________________________ + Mesen renders this tile? (Y/N): __ + +[2] Title "F" — rendered part: + tile CHR $____ bg pal __ + CHR hash ________ + verdict: ____________________________________ + +[3] Title "F" — cut-off part: + tile CHR $____ bg pal __ + CHR hash ________ + verdict: ____________________________________ + (if APPLIED but not drawn) offset_x/offset_y: __ / __ image #: __ out (x,y): __ / __ + +Pack metadata: + line (does it contain disableOriginalTiles?): _______________ + A sample BG line for dungeon art: ______________________________ + Is the pack folder or zip?: ____ Run-ahead enabled?: ____ +``` + +## Constraints on any fix + +- HD-pack is **output-only** and `hd-pack`-gated. Any fix must keep the core + byte-identical (default / `no_std` / wasm builds unchanged) and **AccuracyCoin + 139/139**. The PPU telemetry must not perturb the deterministic framebuffer or + audio — prove on/off byte-identity for PPU-side changes. +- Touch the chip docs in the same change as chip code (`docs/ppu-2c02.md` for PPU + telemetry, `docs/adr/0014` for compositor/condition changes). +- No commercial ROMs in the repo; the Zelda ROM + HD-pack stay local. Any + regression fixture must be synthetic (the existing `hdpack` unit tests are the + template) or a CC0/public-domain pack. + +## Investigation log + +- **2026-06-24:** Symptoms reported (partial title glyphs, black in-game BG, + sprites OK). Ruled out hypotheses 1–6 above by code review. Confirmed the Pixel + Inspector reports the verdict + `pal` + `CHR hash` needed to pinpoint the + matching failure. Awaiting inspector readings + a Mesen2 cross-check. Authored + this document. diff --git a/docs/libretro/advanced_features.md b/docs/libretro/advanced_features.md new file mode 100644 index 000000000..0675206c1 --- /dev/null +++ b/docs/libretro/advanced_features.md @@ -0,0 +1,41 @@ +# RustyNES Libretro Core Advanced Subsystems & Features + +To fulfill RustyNES's preservation-grade feature set (GGPO rollback, RetroAchievements, FDS support), the FFI wrapper must deeply integrate with advanced `libretro.h` subsystems, exposing the strict deterministic capabilities of the `rustynes-core` engine. + +## Direct Memory Mapping & RetroAchievements + +Traditionally, achievement networks like `rcheevos` utilize `READ_CORE_RAM` function pointers, which incur massive function-call overhead by querying memory one byte at a time. +RustyNES must bypass this by utilizing the `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` hook. This exposes an array of `retro_memory_descriptor` structures directly mapping the emulator's 6502 CPU virtual address space to physical Rust heap pointers. + +* **Work RAM (WRAM):** Maps address range `$0000 - $07FF`. Flagged as `RETRO_MEMDESC_SYSTEM_RAM`. +* **Save RAM (SRAM):** Maps address range `$6000 - $7FFF`. Flagged as `RETRO_MEMDESC_SAVE_RAM`. +* **Video RAM (VRAM):** Maps address range `$2000 - $2FFF`. Flagged as `RETRO_MEMDESC_VIDEO_RAM`. + +By exposing these pointers right after `retro_load_game`, RetroAchievements clients hash and observe memory natively, guaranteeing cycle-accurate achievement tracking without stalling the `on_run` loop. + +## SRAM and Virtual File System (VFS) Offloading + +As `rustynes-core` is `no_std`, it possesses no ability to interact with the host OS filesystem (`std::fs`), making native `.srm` (battery save) file writing impossible. +**Solution:** The FFI wrapper exposes the active cartridge's SRAM pointer via `retro_get_memory_data(RETRO_MEMORY_SAVE_RAM)`. + +* RetroArch automatically manages the lifecycle. Upon game load, the frontend injects data from the host's `.srm` file directly into this pointer. +* Upon shutdown (`retro_deinit`), RetroArch reads the pointer and flushes the data to the disk. + +This architectural inversion ensures compatibility with RetroArch Cloud Sync, mobile sandboxes (iOS/Android), and cross-platform save transfers without touching native filesystem APIs. + +## Deterministic Serialization for GGPO & TAS + +Rollback netplay (GGPO) masks network latency by simulating speculative remote player inputs. When actual inputs arrive later, RetroArch performs a "rollback" by instantly restoring a past emulator state (`retro_unserialize`), applying the true inputs, and fast-forwarding (`retro_run`) to catch up silently. +To support this, `rustynes-libretro` relies on the deterministic serialization engine found in `rustynes_core::save_state`. + +1. **`retro_serialize_size` Permanency:** The FFI wrapper must return a static, unchanging byte-size integer post-ROM-load. Dynamic save-state resizing will immediately fault RetroArch, as the frontend pre-allocates contiguous memory pools for rollback frames based on this initial size query. +2. **Implementation:** + * `on_serialize(buffer: &mut [u8])`: Instantiate `rustynes_core::save_state::BinWriter` targeting the `buffer`. The engine pushes exact byte-for-byte state. + * `on_unserialize(buffer: &[u8])`: Pass the `buffer` to `BinReader`. Determinism guarantees the state is restored perfectly without desync. +3. **Fast-Forward Optimization (`get_fastforwarding`):** If the frontend is fast-forwarding to catch up during a rollback, the FFI wrapper should skip copying audio into the batch buffer and optionally skip rendering logic to vastly increase throughput. + +## Famicom Disk System (FDS) Subsystem Negotiation + +Standard NES ROMs (`.nes`) bundle all data in a single file. The Famicom Disk System requires two distinct components: the `.fds` disk image and the `disksys.rom` BIOS. +Using `RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO`, the core declares the "FDS" operating mode. +When selected, RetroArch triggers a specialized `retro_load_game_special` hook, passing multiple memory buffers simultaneously. The core extracts both the BIOS buffer and the Disk buffer, forwarding them securely to `rustynes_core::Nes::from_disk(disk_bytes, bios_bytes)`. This provides a pristine, CLI-free user experience entirely mediated by RetroArch's UI. diff --git a/docs/libretro/architecture.md b/docs/libretro/architecture.md new file mode 100644 index 000000000..53fe6c6d2 --- /dev/null +++ b/docs/libretro/architecture.md @@ -0,0 +1,47 @@ +# RustyNES Libretro Core Integration Architecture + +## Executive Overview + +The emulation landscape relies heavily on decoupled architectural patterns where central processing and synthesis (the emulation engine) are distinct from host OS dependencies (rendering contexts, audio streams, gamepad hooks). The **Libretro API** is the foremost C Application Binary Interface (ABI) facilitating this separation. This document outlines the exhaustive architecture required to embed the **RustyNES** core—a pure Rust, cycle-accurate, master-clock precision Nintendo Entertainment System emulator—into a dynamically linked Libretro module (`cdylib`). + +## Target Workspace and Compile Targets + +The `rustynes-core` crate is strictly `#![no_std]` capable. It relies exclusively on `alloc` for heap allocations, remaining entirely agnostic to the host system. +To bridge this, we establish a new crate, `rustynes-libretro`, defined explicitly with the `crate-type = ["cdylib"]` compilation target. + +1. **The `cdylib` Target:** This forces the `rustc` compiler to build a C-compatible shared object (`.so` on Linux, `.dll` on Windows, `.dylib` on macOS). It strips out Rust-specific ABI metadata and embeds the required parts of the Rust standard library statically. This allows the RetroArch frontend to invoke the module dynamically via `dlopen` / `LoadLibrary` at runtime. +2. **`no_std` Preservation:** The `rustynes-libretro` crate depends on `rustynes-core` via a local path dependency with `default-features = false`. The FFI wrapper may utilize `std` to interop with the host, but the core engine remains purely deterministic and isolated. + +## The Abstraction Layer (`rust-libretro`) + +Direct interaction with the `libretro.h` C-header via raw `unsafe` Rust is highly error-prone, fraught with segmentation faults due to mismatched pointers and undefined behavior. +We employ the **`rust-libretro`** wrapper crate as our architectural foundation. + +* **`rust-libretro-sys`**: Provides the raw, `bindgen`-generated C types. +* **`rust-libretro`**: Provides the safe Rust trait `Core`. + +The `CoreWrapper` struct in `rust-libretro` securely encapsulates frontend callbacks and maintains the static state required by the C ABI. + +## System Topology & State Management + +The architecture operates in a strictly synchronous top-down 3-layer topology: + +### 1. The Libretro Frontend (RetroArch) + +The execution host. Responsible for drawing windows via OpenGL/Vulkan, opening audio devices (WASAPI/ALSA), capturing USB gamepads, and invoking the Libretro loop exactly once per video frame (~60.0988 Hz). + +### 2. The FFI Bridge (`rustynes-libretro`) + +The integration layer. It maintains a struct (e.g., `RustyNesLibretro`) implementing `rust_libretro::core::Core`. + +* **State Maintenance:** It holds `Option`. The `Nes` is instantiated only *after* `retro_load_game` provides valid ROM bytes. +* **Buffer Management:** It maintains a persistent, reusable `Vec` allocated on the heap to act as a zero-allocation audio format converter (scaling `f32` to `i16`). +* **Translation:** Translates RetroPad boolean input queries into `rustynes_core::Buttons` bitmasks. + +### 3. The Emulation Engine (`rustynes-core`) + +The immutable heart. Once initialized, the FFI bridge invokes `nes.run_frame()`. The engine operates in lockstep PPU-dot resolution. It is entirely unaware that it is running inside RetroArch. It returns a contiguous RGBA8 `&[u8]` framebuffer and makes `f32` normalized audio available via `nes.drain_audio()`. + +## Thread Safety and Mutability + +While `rustynes-core` executes synchronously, frontends often invoke video rendering callbacks on asynchronous hardware threads (e.g., Vulkan contexts). To prevent mutex contention, `rustynes-libretro` must ensure that localized mutable state (`Nes`) is exclusively manipulated during the primary `on_run` thread slice, avoiding globally shared `static mut` state outside of the guarantees provided by `rust-libretro`'s `CoreWrapper`. diff --git a/docs/libretro/implementation_specifics.md b/docs/libretro/implementation_specifics.md new file mode 100644 index 000000000..d351612c1 --- /dev/null +++ b/docs/libretro/implementation_specifics.md @@ -0,0 +1,73 @@ +# RustyNES Libretro Core Implementation Specifics + +This document defines the strict, code-level integration parameters governing geometry negotiation, execution loops, and IO translation between the Libretro C ABI and `rustynes-core`. + +## System Initialization and Environment Negotiation + +Before execution, RetroArch extracts core metadata via `retro_get_system_info`. + +* **Library Name & Version:** `"RustyNES"` + `env!("CARGO_PKG_VERSION")`. +* **VFS Offload:** `need_fullpath = false` ensures RetroArch reads the ROM into a RAM buffer (`*const c_void`), passing it directly to `rustynes-libretro`, completely bypassing the need for `std::fs` operations on the core side. + +### Environment Handshakes (`on_set_environment`) + +The core must configure specific frontend states during initialization: + +- **Format Negotiated:** XRGB8888 (32-bit). +- **Format Emitted:** The `rustynes-core` native PPU rendering produces RGBA8. To satisfy the Libretro XRGB8888 standard, the `rustynes-libretro` bridge explicitly swizzles the red and blue channels (`chunk.swap(0, 2)`) on a per-pixel basis during the framebuffer copy before dispatching to `retro_video_refresh_t`. +- **Framebuffer Size:** Strictly 256x240 pixels. No dynamic overscan cropping is exposed at the libretro API boundary, ensuring uniform frame cadence. +* **Input Descriptors:** `RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS` maps underlying Libretro `JOYPAD` constants to human-readable strings (e.g., "NES A Button") to populate the RetroArch control remapping menu. + +## Game Loading (`retro_load_game`) + +The core receives `data: &[u8]` from the frontend. + +* Call `rustynes_core::Nes::from_rom(data)`. +* If a `RomError` is encountered (e.g., invalid iNES header), the FFI wrapper must catch it and return `false` to the frontend, preventing a hard crash. + +## Synchronous Execution Loop (`on_run`) + +The `retro_run` hook fires exactly once per video frame. All operations inside this loop must be zero-allocation (no `String` creation, no dynamic `Vec` allocations) to prevent GC pauses or heap fragmentation micro-stutters. + +### Input Polling and Bitmasking + +1. Invoke `retro_input_poll()` to flush host hardware USB/Bluetooth queues. +2. Query `retro_input_state()` for Port 1 and Port 2. +3. Map the booleans into `rustynes_core::Buttons`: + + ```rust + let mut btns = rustynes_core::Buttons::empty(); + if ctx.get_joypad_bit(0, JoypadButton::A) { btns.insert(Buttons::A); } + if ctx.get_joypad_bit(0, JoypadButton::B) { btns.insert(Buttons::B); } + // ... map Select, Start, Up, Down, Left, Right ... + nes.set_controller_state(0, btns); // Adjust per actual Nes API for Joypads + ``` + +### Video Rendering & Geometry + +Invoke `nes.run_frame()`, which yields an `&[u8]`. + +* **Geometry Calculation:** Standard NTSC NES output is `width: 256`, `height: 240`. +* **Pitch Alignment:** Libretro defines pitch as the exact byte-width of one scanline. For 32-bit `XRGB8888`, pitch is `256 * 4 = 1024` bytes. +* **Callback Dispatch:** Pass the buffer pointer, width, height, and pitch directly to `retro_video_refresh_callback`. +* **Frame Duplication Optimization:** During fast-forward sequences where the emulator state may not visually change, passing a `NULL` pointer instructs RetroArch to duplicate the previous frame, saving massive host GPU bandwidth. + +### Audio Synchronization & Batching + +RustyNES outputs `f32` audio samples normalized to `[0.0, ~1.0]` (or `[-1.0, 1.0]`). +Libretro requires interleaved stereo `i16` delivered in a single batch (avoiding mutex starvation associated with single-sample callbacks). + +1. `let f32_samples = nes.drain_audio();` +2. Clear the persistent `self.audio_buffer` (capacity pre-allocated to ~2000 i16s). +3. **Conversion Math:** + For a normalized `[0.0, 1.0]` float, map it to `[-32768, 32767]`: + + ```rust + for &sample in &f32_samples { + let scaled = (sample * 65535.0 - 32768.0).clamp(-32768.0, 32767.0) as i16; + self.audio_buffer.push(scaled); // Left channel (mono duplicate) + self.audio_buffer.push(scaled); // Right channel (mono duplicate) + } + ``` + +4. Dispatch the slice via `retro_set_audio_sample_batch`. The frontend's dynamic resampler handles fractional timing slips automatically. diff --git a/ref-docs/RustyNES-Libretro_Core.md b/ref-docs/RustyNES-Libretro_Core.md new file mode 100644 index 000000000..0c4036706 --- /dev/null +++ b/ref-docs/RustyNES-Libretro_Core.md @@ -0,0 +1,277 @@ +# **Integration Architecture and Implementation Blueprint for the RustyNES Libretro Core** + +## **Executive Overview** + +The paradigm of modern emulation architecture has shifted decisively toward modularity, wherein the highly specialized logic of central processing unit (CPU) emulation and audio-visual synthesis is decoupled from the platform-dependent complexities of host operating systems. The Libretro Application Programming Interface (API) represents the industry standard for this architectural separation, serving as the connective tissue between emulation cores and frontends such as RetroArch. The objective of this comprehensive research report is to delineate an exhaustive architectural strategy, define the precise technical specifications, and construct a phased implementation blueprint for porting the "RustyNES" central emulation core into a fully compliant Libretro shared library. +RustyNES, architected by the developer "doublegate," is a cycle-accurate Nintendo Entertainment System (NES) emulator engineered entirely in the Rust programming language.1 The emulator distinguishes itself within the preservation community through a robust and modern feature set, encompassing support for over 300 proprietary NES memory mappers, WebAssembly (WASM) readiness, deterministic state serialization for GGPO rollback netplay, Tool-Assisted Speedrun (TAS) utilities, and native RetroAchievements integration.1 Crucially, the internal engine of the emulator, encapsulated within the rustynes-core crate, is strictly no\_std capable, meaning it relies on neither the Rust standard library nor host operating system primitives.2 This design constraint makes it exceptionally portable but requires a robust interface layer to interact with standardized desktop or mobile frontends. +Transitioning the RustyNES central core to the Libretro API necessitates the development of a Foreign Function Interface (FFI) wrapper. This wrapper must seamlessly translate the C Application Binary Interface (ABI) callbacks mandated by RetroArch—such as retro\_init, retro\_load\_game, and retro\_run—into the idiomatic, memory-safe Rust paradigms employed by the RustyNES engine.4 By mapping RustyNES's cycle-accurate state machine, contiguous video frame buffers, and synthesized audio generators into Libretro's synchronous event loop, RustyNES will become directly selectable and executable within RetroArch and any other Libretro-compatible frontend. +This deep research plan systematically defines the requisite system architecture, dependency selection criteria, memory management protocols, detailed technical specifications, and a multi-phased implementation blueprint required to bridge the no\_std rustynes-core crate with the expansive Libretro ecosystem. + +## **Architectural Philosophy and the Foreign Function Interface** + +To integrate a pure Rust emulator into the RetroArch ecosystem, the compilation target must produce a dynamically linked, C-compatible shared library. Libretro frontends rely on dynamic loading mechanisms (e.g., dlopen on POSIX systems or LoadLibrary on Windows) to load these core libraries at runtime.6 The resulting binary is typically a .so file on Linux, a .dll on Windows, and a .dylib on macOS.8 Therefore, the Rust build system must be explicitly configured to output a specific crate type that conforms to these dynamic loading standards. +Historically, the Rust compiler supported a dynamic library target known as rdylib. This format was intended for Rust-to-Rust dynamic linking, exposing all internal Rust symbols and linking to the Rust standard library dynamically. However, because Rust lacks a stable ABI, rdylib proved highly impractical for interfacing with other languages. To solve this, the cdylib crate type was introduced via RFC 1510\.10 The cdylib target instructs the compiler to generate a dynamic library that exports a C API, strips unnecessary Rust-specific metadata, and statically bundles the required Rust standard library components.10 This produces a lean, highly optimized binary that the RetroArch frontend can load without requiring a host Rust runtime.9 + +### **The Abstraction Layer Strategy** + +Directly writing raw C-ABI exports for the extensive Libretro API using unsafe Rust is highly error-prone. The libretro.h header contains dozens of callbacks, nested structs, and hardware capability flags that must be negotiated.11 To mitigate the risks of undefined behavior, memory leaks, and segmentation faults across the FFI boundary, the Rust emulation community has developed several wrapper crates. These crates encapsulate the raw bindings—usually generated via bindgen into a sys crate 13—into safe, idiomatic Rust traits. +Selecting the optimal abstraction layer is the most critical architectural decision in this integration plan. The chosen crate will dictate how RustyNES interacts with the frontend and how easily its advanced features (GGPO, TAS, RetroAchievements) can be exposed. + +| Abstraction Crate | Primary Author | Architectural Approach and Mechanics | Viability and Technical Assessment for RustyNES | +| :---- | :---- | :---- | :---- | +| rust-libretro | max-m | Provides comprehensive abstractions via Core and CoreOptions traits. Utilizes a CoreWrapper struct to manage frontend runtime data, audio/video synchronization, and environment callbacks securely behind safe Rust references.15 Actively handles advanced environment queries, variable updates, and hardware contexts.17 | **High Viability**. The context modules and safe callback wrappers are ideal for supporting RustyNES's advanced features, including precise memory mapping and deterministic serialization for GGPO.17 | +| libretro-backend | koute | Exposes a simplified Core trait and utilizes a libretro\_core\! procedural macro to automatically generate the necessary FFI exports.8 Designed explicitly for emulator authors to relieve them from frontend creation. Powers the well-regarded "Pinky" NES emulator.8 | **Moderate Viability**. While proven in the domain of NES emulation, the author notes that it is missing advanced features in its current state.8 It may lack the granular environment hooks required for RustyNES's TAS and achievement systems. | +| libretro-core-rs | jefersondaniel | Wraps the C ABI behind a single Core trait. Handles retro\_\* symbol exports, environment callbacks, and audio-visual information negotiation natively through a safe-ish trait implementation.19 | **Moderate Viability**. Provides a functional trait approach but has sparse documentation regarding deep state manipulation, serialization sizing, and direct memory mapping.19 | + +Given the profound complexity of the RustyNES engine—specifically its strict requirement for cycle-accurate deterministic state serialization (vital for GGPO netplay) and direct memory mapping capabilities (vital for zero-overhead RetroAchievements)—the rust-libretro wrapper presents the most robust and extensible architectural foundation.1 It is backed by rust-libretro-sys, which provides the raw bindings to the API 13, and it offers highly granular control over the retro\_environment\_t callbacks.17 + +### **Topology of the System Architecture** + +The target architecture will consist of three distinct, cleanly separated conceptual layers. This separation of concerns ensures that the no\_std core remains untouched and theoretically provable, while the FFI bridge handles all platform-specific negotiations. + +1. **The Libretro Frontend (RetroArch):** This layer manages the host operating system context. It is responsible for drawing windows via OpenGL/Vulkan/Direct3D, opening host audio devices via ALSA/WASAPI/PulseAudio, and capturing physical joypad inputs via DirectInput/XInput/udev.21 The frontend communicates exclusively through the C ABI. +2. **The FFI Bridge (rustynes-libretro Crate):** A newly instantiated cdylib crate acting as the architectural glue. It implements the rust-libretro Core trait, maintains the global state required by the C-ABI (wrapped safely in CoreWrapper), translates RetroPad inputs to NES inputs, and forwards cycle execution calls to the underlying engine.15 +3. **The Emulation Engine (rustynes-core Crate):** The unmodified, no\_std capable, cycle-accurate logic of RustyNES. It receives generic generic data structures, executes 6502 CPU instructions and Picture Processing Unit (PPU) rendering phases, and outputs raw pixel and audio arrays without any knowledge of the host environment.2 + +## **Core Lifecycle Management and Environment Negotiation** + +The Libretro API enforces a strict, highly predictable execution lifecycle. The core must implement a sequence of initialization functions to negotiate capabilities, allocate memory, and load game data before the synchronous execution loop can begin.4 The implementation of these lifecycle hooks within the FFI bridge is paramount to stability. + +### **The System Information Handshake** + +The primary interaction between RetroArch and the core occurs via the retro\_get\_system\_info function.24 When the frontend scans a directory of ROMs or when a user selects the core, RetroArch queries this function to understand what the core is capable of executing. The FFI wrapper must populate the retro\_system\_info struct with statically known data.4 + +| Field Name | Data Type | Implementation Value and Rationale | +| :---- | :---- | :---- | +| library\_name | \*const c\_char | "RustyNES". This string is displayed in the RetroArch user interface to identify the loaded core. | +| library\_version | \*const c\_char | The dynamically injected Cargo version (e.g., "1.8.8"). Reflects the current release state of the core.25 | +| valid\_extensions | \*const c\_char | \`"nes | +| need\_fullpath | bool | false. If set to true, RetroArch passes the file path, and the core must open the file itself. Setting this to false instructs RetroArch to load the file into memory and pass a pointer to the buffer, which is highly preferred for standard ROMs.27 | +| block\_extract | bool | false. Indicates whether the core supports loading from compressed archives. RetroArch natively handles ZIP extraction before passing the buffer, so this can remain false.28 | + +### **Environment Callbacks and Capability Declarations** + +During initialization, RetroArch calls retro\_set\_environment, passing a callback function pointer (retro\_environment\_t).5 The rust-libretro crate wraps this in the on\_set\_environment trait method. This is where the core negotiates advanced features and queries the frontend's capabilities.16 +The implementation must securely define the following environment parameters: + +1. **Pixel Format Negotiation:** By default, the Libretro specification expects cores to render in a legacy 15-bit 0RGB1555 pixel format.12 For a modern Rust emulator like RustyNES, which likely utilizes a true-color palette for accurate NTSC artifact rendering, the environment must be queried using the RETRO\_ENVIRONMENT\_SET\_PIXEL\_FORMAT constant. The core must request the 32-bit XRGB8888 format.30 If the frontend rejects this request, the FFI wrapper must instantiate a dynamic color-space conversion loop to downsample the 32-bit buffer to 15-bit before passing it to the video callback, ensuring compatibility with older hardware. +2. **Input Descriptor Mapping:** To enhance the user experience, the core should utilize RETRO\_ENVIRONMENT\_SET\_INPUT\_DESCRIPTORS.31 This callback allows the core to pass an array of retro\_input\_descriptor structs.12 These structs map the generic Libretro input binds (e.g., RETRO\_DEVICE\_ID\_JOYPAD\_B) to human-readable strings relevant to the emulated system (e.g., "NES A Button"). This information populates RetroArch's control configuration menu, allowing users to intuitively remap their physical controllers.12 +3. **Core Options and Variables:** The core can expose internal emulator settings—such as color palette selection, overscan cropping, or audio channel muting—to the RetroArch GUI using the RETRO\_ENVIRONMENT\_SET\_VARIABLES callback.17 The frontend will present these variables in the "Core Options" menu and notify the core of changes via the set\_variable hook.17 + +### **The Game Loading Protocol** + +When a user executes a game, RetroArch calls the retro\_load\_game function.7 The frontend passes a retro\_game\_info struct containing a pointer to the loaded ROM data and its exact size.27 +The FFI bridge must execute an unsafe block to dereference this pointer, converting the raw C memory buffer into a safe Rust byte slice (&\[u8\]). This slice is then passed into the rustynes-core initialization logic.7 The core's internal logic will parse the iNES or NES 2.0 header, identify the required mapper (from its pool of 300+ supported mappers), allocate the necessary virtual RAM, and construct the system state machine.1 If the ROM header is malformed or the mapper is unsupported, the retro\_load\_game function must return false, signaling to RetroArch that initialization has failed, preventing a hard crash.27 +Upon successful ROM loading, the core must subsequently execute retro\_get\_system\_av\_info to define the base geometry and timing of the system.17 The internal resolution will be strictly defined (typically 256x240 for standard NTSC NES titles, though overscan cropping options may alter this), alongside the precise NTSC frame rate (![][image1] Hz) and the target audio sample rate (e.g., 48,000 Hz).32 This structural data allows RetroArch to configure its internal audio resamplers and video scalers to perfectly match the core's output. + +## **Synchronous Execution Loop and Timing Architecture** + +The heartbeat of any Libretro implementation is the retro\_run function. RetroArch invokes this function within a synchronous loop exactly once per video frame.4 During this single function call, the core must poll for input, execute CPU instructions until a full frame is rendered, push the generated video buffer to the frontend, and push the synthesized audio buffer to the frontend.4 The orchestration of these tasks within the FFI boundary requires meticulous attention to memory alignment and thread safety. + +### **Geometry Negotiation and Video Rendering Constraints** + +The video rendering pipeline in Libretro is handled by the retro\_video\_refresh\_callback.6 When rustynes-core completes the emulation of a single frame, it yields a raw array of pixels representing the NES output. The FFI wrapper must pass this array to the frontend. +A critical technical specification in this process is the handling of the "pitch" parameter. The pitch specifies the length, in bytes, between the start of two consecutive scanlines in the memory buffer.6 + +| Rendering Parameter | Technical Specification and Calculation | +| :---- | :---- | +| **Base Resolution** | **![][image2]** pixels (Standard NTSC). | +| **Pixel Format** | 32-bit XRGB8888 (4 bytes per pixel). | +| **Pitch Calculation** | Width (![][image3]) ![][image4] Bytes per Pixel (![][image5]) \= ![][image6] bytes. | +| **Total Buffer Size** | Pitch (![][image6]) ![][image4] Height (![][image7]) \= ![][image8] bytes per frame. | + +For optimal CPU cache coherency and rendering throughput, the Libretro documentation heavily advises keeping the frame tightly packed in memory, ensuring that pitch \== width \* bytes\_per\_pixel.29 The internal PPU buffer of RustyNES must guarantee this contiguous alignment before passing the reference across the FFI boundary to retro\_video\_refresh. +Furthermore, if the core detects that the emulator state has not changed visually—for example, during purposeful frame drops while fast-forwarding, or if the game is rendering at a lower effective framerate—the core can utilize frame duplication by passing a NULL pointer to the video callback.17 This signals the frontend to redraw the previous frame, drastically reducing host GPU bandwidth overhead. + +### **Audio Subsystem Synchronization and Batch Processing** + +The Libretro API offers two distinct pathways for audio output: a single-sample callback (retro\_set\_audio\_sample) and a batched audio callback (retro\_set\_audio\_sample\_batch).4 Historical implementation data from Libretro developers dictates that the batch callback must be utilized for acceptable performance in modern frontends.35 +Executing a single-sample callback requires bridging the C-FFI boundary tens of thousands of times per second (e.g., 96,000 times for a stereo 48kHz signal). This leads to severe frontend mutex locking, CPU busy-waiting, and degraded frame pacing, as the frontend and the core constantly fight over thread synchronization.32 +The batch implementation requires the rustynes-core Audio Processing Unit (APU) to fill an internal ring buffer over the course of the frame. The data must be formatted as 16-bit signed integers in native endianness.33 Libretro audio is strictly interleaved stereo, meaning the samples must alternate continuously (Left, Right, Left, Right).33 Because the NES natively generates monaural audio, the FFI wrapper must duplicate each synthesized sample into both the left and right channels within the buffer before dispatching it to the frontend. +**Mathematical Buffering Specification:** Assume a target audio sampling rate of ![][image9] Hz, negotiated during retro\_get\_system\_av\_info.32 For an exact ![][image10] Hz frame rate, the required sample generation per frame is calculated as follows: +![][image11] +Because each audio frame requires two distinct samples (Left and Right for interleaved stereo), the total buffer size passed to retro\_set\_audio\_sample\_batch must be exactly ![][image12] int16\_t values per retro\_run invocation.33 If the emulator runs slightly faster or slower than exactly 60 Hz (e.g., 60.0988 Hz for precise NTSC timing), the sample count must be adjusted dynamically per frame, and the frontend's dynamic rate control (audio resampler) will handle the synchronization to prevent audio crackling.32 + +### **Input Polling and the RetroPad Abstraction** + +RetroArch abstracts all physical host controllers (keyboards, gamepads, arcade sticks) via an interface known as the "RetroPad".22 The rustynes-libretro core must poll this generic abstraction and translate the data into the strict bit-shifted values expected by the NES controller registers ($$4016 and $$4017).38 +At the very beginning of the retro\_run loop, the implementation must execute retro\_input\_poll().4 This is a command to the frontend to update its internal hardware queues, polling the host USB/Bluetooth devices. Immediately following this, the core iterates over the required buttons using retro\_input\_state().4 The rust-libretro crate provides safe wrappers for these functions, allowing the FFI bridge to query the boolean state of the RetroPad's A, B, Select, Start, Up, Down, Left, and Right constants. These boolean values are then packed into the 8-bit integer format expected by the rustynes-core input handler. +If RustyNES natively supports advanced or niche peripherals—such as the NES Zapper (lightgun), the Arkanoid Vaus Controller (paddle), or the Power Pad 39—the RETRO\_ENVIRONMENT\_SET\_CONTROLLER\_INFO callback must be implemented during initialization.31 This allows the core to declare subclass devices using the RETRO\_DEVICE\_SUBCLASS macro.38 By defining these subclasses, the frontend GUI will automatically present users with a drop-down menu of selectable peripherals for Port 1 and Port 2, ensuring seamless integration of the NES's diverse hardware ecosystem.11 + +## **Advanced Integration Pipelines and System Extensibility** + +To fully leverage the unique architectural selling points of RustyNES—specifically its stated support for "WebAssembly-ready, GGPO netplay, TAS tools, and RetroAchievements integration" 1—the Libretro wrapper must dive significantly deeper into the experimental and advanced facets of the libretro.h specification. Standard video and audio output is insufficient for a preservation-grade emulator. + +### **RetroAchievements and Direct Memory Mapping** + +RetroAchievements is a community-driven platform that adds modern achievement hunting to classic retro games. The logic for triggering these achievements is driven by the rcheevos library, which evaluates complex "rich presence scripts" by constantly peeking at the emulated system's Random Access Memory (RAM).40 +Historically, emulators provided a READ\_CORE\_RAM callback, which forced the achievement client to request memory values one byte at a time through a function pointer. This introduces massive overhead. The optimal, modern technical route for RustyNES is to utilize the RETRO\_ENVIRONMENT\_SET\_MEMORY\_MAPS environment call.31 +This advanced feature allows the RustyNES core to expose an array of retro\_memory\_descriptor structures to the frontend.37 Each descriptor accurately maps a section of the virtual address space of the 6502 CPU directly to physical pointers on the Rust heap.43 +The implementation must define the following mappings: + +| Memory Region | Address Range | Libretro Descriptor Flag | Purpose and Achievement Relevance | +| :---- | :---- | :---- | :---- | +| **Work RAM (WRAM)** | $0000 \- $07FF | RETRO\_MEMDESC\_SYSTEM\_RAM | The primary 2KB internal RAM of the NES. Contains nearly all game state variables, player health, score, and level progression data. Critical for achievement evaluation.30 | +| **Save RAM (SRAM)** | $6000 \- $7FFF | RETRO\_MEMDESC\_SAVE\_RAM | Battery-backed 8KB RAM provided by the cartridge mapper (if present). Used for long-term progression in games like *The Legend of Zelda*.30 | +| **Video RAM (VRAM)** | $2000 \- $2FFF | RETRO\_MEMDESC\_VIDEO\_RAM | The PPU NameTables. While less commonly used for achievements, exposing this allows advanced scripts to detect on-screen text or map changes.30 | + +By mapping these pointers dynamically after the ROM is analyzed in retro\_load\_game, the RetroAchievements client can directly hash, monitor, and observe memory states without any FFI function-call overhead. This secures cycle-accurate achievement triggering and guarantees compatibility with the platform.11 + +### **Deterministic Serialization for GGPO Rollback and Tool-Assisted Speedruns** + +RustyNES's stated support for GGPO (Good Game Peace Out) netplay and TAS (Tool-Assisted Speedrun) tools implies that the underlying engine is inherently deterministic and capable of deep state serialization.1 Determinism means that given the exact same initial state and the exact same sequence of inputs, the emulator will always produce the exact same final frame. The Libretro API accommodates these advanced features via three mandatory export callbacks: retro\_serialize\_size, retro\_serialize, and retro\_unserialize.16 +RetroArch uses these callbacks continuously, far beyond simple user-initiated save states. During network play using the GGPO rollback mechanism, latency is masked by predicting remote player inputs. When a remote player's actual input arrives later than the predicted execution frame, RetroArch will perform a rollback. It utilizes retro\_unserialize to restore the emulator to a known past state, applies the newly arrived late inputs, and then fast-forwards back to the present frame via silent retro\_run calls.17 +To support this flawlessly, the FFI implementation mandates several strict constraints: + +1. **Serialization Size Permanency:** The value returned by the retro\_serialize\_size function must remain fixed after the game is loaded.46 Dynamic resizing of the save state buffer during runtime causes undefined behavior and segmentation faults in frontends, as they pre-allocate memory pools for rollback frames based on this initial query. +2. **Memory Layout Stability:** If RustyNES utilizes Rust crates like bincode or serde for serialization, the output format must be highly predictable. Padding, alignment offsets, and the structural topology of the serialized payload must not fluctuate across identical frames. +3. **Fast-Forward Optimizations:** When the core is rolling back to catch up to the current frame, RetroArch sets an internal fast-forward environment flag.17 The FFI wrapper should intercept this via the get\_fastforwarding hook.17 If this flag is true, RustyNES should bypass expensive, non-essential operations—such as synthesizing the audio buffer or rendering complex visual filters—to maximize CPU throughput during the rollback simulation.17 + +### **Save RAM (SRAM) and Virtual File System (VFS) Management** + +To ensure cross-platform compatibility of in-game saves (such as RPG progression), the FFI wrapper must not attempt to perform its own host file I/O operations. Because rustynes-core is no\_std, it lacks the ability to open files natively anyway.2 +Instead, the core must interface with RetroArch's memory management hooks. The frontend will query the core using retro\_get\_memory\_data and retro\_get\_memory\_size for the specific RETRO\_MEMORY\_SAVE\_RAM identifier.30 The FFI wrapper returns a pointer to the internal SRAM buffer allocated by the active mapper. +Upon shutting down the core (retro\_deinit), RetroArch automatically reads this exposed pointer and serializes the data to an .srm file on the host disk.28 Conversely, upon loading a game, RetroArch reads the .srm file and copies the data into the pointer provided by the core. This architectural inversion of control ensures that features like RetroArch's Cloud Sync, cross-platform save transfers, and strict sandboxing (crucial for mobile operating systems like iOS and Android) function seamlessly without any file I/O contention from the emulation core.48 +For advanced operations requiring file loading (such as loading Famicom Disk System BIOS files or applying IPS/BPS patches), the core should leverage the Libretro Virtual File System (VFS) API. Callbacks such as retro\_vfs\_file\_handle, retro\_vfs\_read\_t, and retro\_vfs\_close\_t provide a safe, frontend-managed pathway to read secondary assets from the host disk without relying on the Rust standard library's std::fs.12 + +## **Phased Implementation Blueprint** + +The transformation of RustyNES into a Libretro core must be executed methodically to isolate bugs at the FFI boundary. The following implementation blueprint details the sequential phases required to achieve full RetroArch compatibility. + +### **Phase 1: Build System Bootstrapping and Workspace Configuration** + +The initial phase involves structuring the Cargo workspace to accommodate a new target without polluting the standalone application builds (such as mobile or WASM targets).25 + +1. **Crate Initialization:** Initialize a new crate named rustynes-libretro within the RustyNES workspace directory. +2. **Cargo Configuration:** Modify the Cargo.toml to enforce the cdylib compilation target.8 +3. **Dependency Linking:** Inject rustynes-core as a path dependency. Crucially, explicitly declare default-features \= false to ensure the core remains strictly no\_std.2 Add rust-libretro and rust-libretro-sys as external dependencies to provide the API definitions.14 + +### **Phase 2: Core Lifecycle and Dummy State Negotiation** + +Before any emulation logic is integrated, the FFI bridge must be proven stable. + +1. **Trait Implementation:** Implement the rust-libretro Core trait on a generic RustyNESWrapper struct.15 +2. **System Information (retro\_get\_system\_info):** Populate the retro\_system\_info struct with the core name, version, and valid extensions ("nes|fds").4 +3. **Environment Handshake:** Implement on\_set\_environment to request the 32-bit XRGB8888 pixel format and declare the standard RetroPad input descriptors.16 +4. **Compilation and Frontend Testing:** Compile the cdylib and load it into RetroArch via the command line interface (retroarch \-L rustynes\_libretro.so).50 Verify that RetroArch successfully identifies the core and does not crash upon initialization. + +### **Phase 3: ROM Loading and the Synchronous Execution Loop** + +With the FFI boundary stable, the actual emulation logic can be connected. + +1. **Game Loading (retro\_load\_game):** Safely cast the raw C pointer provided in the retro\_game\_info struct to a Rust slice, and instantiate the rustynes-core engine.27 If successful, negotiate the audio-visual geometry (retro\_get\_system\_av\_info). +2. **Input Polling Integration:** Inside the on\_run method, query the rust-libretro input contexts (retro\_set\_input\_poll and retro\_set\_input\_state) and map the booleans to the RustyNES controller struct.15 +3. **Clock Execution:** Invoke the RustyNES run\_frame() or step() function, advancing the 6502 CPU and PPU until the vertical blank (VBlank) signals a complete frame. +4. **Video Output:** Pass the contiguous internal PPU buffer to the video refresh callback.6 Ensure the pitch calculation precisely matches the width and bytes-per-pixel. +5. **Audio Output:** Drain the internal APU sample buffer, duplicate the mono samples to stereo, and dispatch the array using the batched audio callback.32 + +### **Phase 4: Advanced Subsystems (Achievements, Netplay, Serialization)** + +The final phase elevates the core from a basic emulator to a competitive, preservation-grade Libretro integration. + +1. **Memory Mapping:** Implement the RETRO\_ENVIRONMENT\_SET\_MEMORY\_MAPS environment call.31 Map the rustynes-core WRAM and SRAM pointers to the frontend to enable zero-overhead RetroAchievements tracking.40 +2. **Serialization Hooks:** Expose RustyNES's deterministic state-saving mechanisms via the retro\_serialize and retro\_unserialize hooks.16 Guarantee that retro\_serialize\_size returns a static constant to prevent frontend memory faults during GGPO rollback.46 +3. **Save Data Management:** Expose the SRAM buffer via retro\_get\_memory\_data to allow RetroArch to handle the reading and writing of .srm files, ensuring cloud sync compatibility.28 + +## **Enhancements and Future-Proofing Strategy** + +To guarantee the longevity and stability of the RustyNES Libretro core, several advanced architectural enhancements should be considered beyond the baseline implementation. + +### **Threaded Rendering and FFI Thread Safety Protocols** + +While RustyNES executes its core logic purely synchronously, RetroArch supports heavily threaded video context drivers to minimize input latency and maximize GPU utilization.47 Extreme care must be taken within the rustynes-libretro FFI layer regarding Rust's Send and Sync traits. If the frontend invokes retro\_video\_refresh on an asynchronous hardware thread, any shared global state must be wrapped in std::sync::Mutex or RwLock. +However, to prevent stuttering and audio dropouts, critical path functions like retro\_run should avoid lock contention at all costs. The architecture should maintain localized, mutable state strictly within the CoreWrapper abstraction provided by the rust-libretro crate, avoiding global static mut variables.15 + +### **Hardware Context Rendering (OpenGL/Vulkan)** + +Currently, cycle-accurate NES emulators utilize software rendering, passing a populated array of pixels directly to the frontend. However, if RustyNES intends to implement advanced video filters, CRT shaders, or hardware-accelerated upscaling in the future, the core must utilize the RETRO\_ENVIRONMENT\_SET\_HW\_RENDER callback.12 +This callback allows the core to negotiate an OpenGL, Vulkan, or Direct3D context with the frontend.21 Instead of passing a raw pixel array, the core draws directly to a framebuffer object (FBO) provided by RetroArch.21 This delegates the heavy lifting of presentation, scaling, and shader application entirely to the host GPU, preserving CPU cycles for accurate 6502 emulation. + +### **Subsystem Implementations for the Famicom Disk System** + +The NES ecosystem includes massive hardware extensions, most notably the Famicom Disk System (FDS), which fundamentally changes the media format from ROM cartridges to rewritable floppy disks. Using the RETRO\_ENVIRONMENT\_SET\_SUBSYSTEM\_INFO callback 52, the core can define multiple operating modes. +If a user wishes to play an FDS game, they must select a .fds file alongside the required BIOS ROM. The subsystem API allows RetroArch to pass multiple retro\_game\_info structs simultaneously into a specialized retro\_load\_game\_special hook.11 The FFI wrapper can then dynamically assemble the FDS peripheral logic inside RustyNES, injecting the BIOS and mounting the disk image, providing a seamless user experience that does not rely on complex command-line arguments. + +## **Conclusion** + +The transformation of the RustyNES central engine into a fully compliant, preservation-grade Libretro module is a complex architectural endeavor requiring a fastidious application of Rust FFI principles, memory management, and lifecycle synchronization. The chosen architecture—leveraging the robust abstractions of the rust-libretro crate and compiling to a dynamically linked cdylib—provides the safest, most performant, and most expressive vector for translating the rigorous demands of the C-ABI into RustyNES’s native, no\_std paradigms. +By meticulously adhering to the strict audio buffering mathematics, precisely negotiating the NTSC geometry and pixel formats, and executing immutable, deterministic state serialization, the integration will natively support RetroArch's most demanding features, including GGPO rollback netplay and real-time Tool-Assisted Speedruns. Furthermore, by explicitly defining internal memory maps through the advanced Libretro environment callbacks, RustyNES will offer flawless, zero-overhead interactions with the RetroAchievements network. This deep technical synthesis will securely bridge doublegate's highly accurate emulator engine with the world's most ubiquitous emulation frontend, ensuring the long-term game preservation, competitive viability, and universal community accessibility of the RustyNES project. + +#### **Works cited** + +1. 6502 · GitHub Topics, accessed June 28, 2026, [https://github.com/topics/6502?o=desc\&s=updated](https://github.com/topics/6502?o=desc&s=updated) +2. fix(security): resolve all 10 open CodeQL code-scanning alerts, accessed June 28, 2026, [https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27594089963/workflow](https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27594089963/workflow) +3. chore(ci): bump actions/checkout from 6 to 7 · doublegate/RustyNES, accessed June 28, 2026, [https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27945063483/job/82687641696](https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27945063483/job/82687641696) +4. Understanding libRetro \- An Internal Look for Programmers \- Retro Reversing, accessed June 28, 2026, [https://www.retroreversing.com/libRetro](https://www.retroreversing.com/libRetro) +5. Discussion: Using ChatGPT to write LibRetro Cores : r/RetroArch \- Reddit, accessed June 28, 2026, [https://www.reddit.com/r/RetroArch/comments/10hclgt/discussion\_using\_chatgpt\_to\_write\_libretro\_cores/](https://www.reddit.com/r/RetroArch/comments/10hclgt/discussion_using_chatgpt_to_write_libretro_cores/) +6. Creating a LibRetro Frontend in Rust \- Retro Reversing, accessed June 28, 2026, [https://www.retroreversing.com/CreateALibRetroFrontEndInRust](https://www.retroreversing.com/CreateALibRetroFrontEndInRust) +7. RetroGameDeveloper/rustro\_arch: A small lightweight LibRetro Frontend written in Rust (learning project) \- GitHub, accessed June 28, 2026, [https://github.com/RetroGameDeveloper/rustro\_arch](https://github.com/RetroGameDeveloper/rustro_arch) +8. koute/libretro-backend: Libretro API bindings for Rust \- GitHub, accessed June 28, 2026, [https://github.com/koute/libretro-backend](https://github.com/koute/libretro-backend) +9. Why do I need to set the crate-type to cdylib to build a wasm binary? \- Rust Users Forum, accessed June 28, 2026, [https://users.rust-lang.org/t/why-do-i-need-to-set-the-crate-type-to-cdylib-to-build-a-wasm-binary/93247](https://users.rust-lang.org/t/why-do-i-need-to-set-the-crate-type-to-cdylib-to-build-a-wasm-binary/93247) +10. 1510-cdylib \- The Rust RFC Book, accessed June 28, 2026, [https://rust-lang.github.io/rfcs/1510-cdylib.html](https://rust-lang.github.io/rfcs/1510-cdylib.html) +11. retro/src/libretro.h at master · openai/retro \- GitHub, accessed June 28, 2026, [https://github.com/openai/retro/blob/master/src/libretro.h](https://github.com/openai/retro/blob/master/src/libretro.h) +12. rust\_libretro\_sys \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro-sys/](https://docs.rs/rust-libretro-sys/) +13. max-m/rust-libretro \- GitHub, accessed June 28, 2026, [https://github.com/max-m/rust-libretro](https://github.com/max-m/rust-libretro) +14. rust-libretro-sys \- crates.io: Rust Package Registry, accessed June 28, 2026, [https://crates.io/crates/rust-libretro-sys](https://crates.io/crates/rust-libretro-sys) +15. CoreWrapper in rust\_libretro::core\_wrapper \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro/latest/rust\_libretro/core\_wrapper/struct.CoreWrapper.html](https://docs.rs/rust-libretro/latest/rust_libretro/core_wrapper/struct.CoreWrapper.html) +16. rust\_libretro \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro/latest/rust\_libretro/](https://docs.rs/rust-libretro/latest/rust_libretro/) +17. rust\_libretro::environment \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro/latest/rust\_libretro/environment/index.html](https://docs.rs/rust-libretro/latest/rust_libretro/environment/index.html) +18. \[deleted by user\] : r/rust \- Reddit, accessed June 28, 2026, [https://www.reddit.com/r/rust/comments/58zu8p/deleted\_by\_user/](https://www.reddit.com/r/rust/comments/58zu8p/deleted_by_user/) +19. jefersondaniel/libretro-core-rs \- GitHub, accessed June 28, 2026, [https://github.com/jefersondaniel/libretro-core-rs](https://github.com/jefersondaniel/libretro-core-rs) +20. libretro-core-rs/crates/libretro-core/libretro\_coverage.md at main, accessed June 28, 2026, [https://github.com/jefersondaniel/libretro-core-rs/blob/main/crates/libretro-core/libretro\_coverage.md](https://github.com/jefersondaniel/libretro-core-rs/blob/main/crates/libretro-core/libretro_coverage.md) +21. Libretro And OpenGL : r/rust \- Reddit, accessed June 28, 2026, [https://www.reddit.com/r/rust/comments/sle7en/libretro\_and\_opengl/](https://www.reddit.com/r/rust/comments/sle7en/libretro_and_opengl/) +22. Getting Started with RetroArch \- Libretro, accessed June 28, 2026, [https://libretro1.rssing.com/chan-36080903/all\_p2.html](https://libretro1.rssing.com/chan-36080903/all_p2.html) +23. Hello, Worldまで3ヶ月 Golangでファミコンエミュレータ実装 \#gocon fukuoka 2019 \- Slideshare, accessed June 28, 2026, [https://www.slideshare.net/slideshow/hello-world3-golang-gocon-fukuoka-2019/155417517](https://www.slideshare.net/slideshow/hello-world3-golang-gocon-fukuoka-2019/155417517) +24. retro\_get\_system\_info in rust\_libretro \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro/latest/rust\_libretro/fn.retro\_get\_system\_info.html](https://docs.rs/rust-libretro/latest/rust_libretro/fn.retro_get_system_info.html) +25. docs: bring README \+ STATUS current to v1.8.5 (power-user, accessed June 28, 2026, [https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27878318991/workflow](https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27878318991/workflow) +26. docs(release): v1.8.8 "Atlas" CHANGELOG \+ README \+ STATUS, accessed June 28, 2026, [https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27890295076/usage](https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27890295076/usage) +27. retro\_game\_info in rust\_libretro\_sys \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro-sys/latest/rust\_libretro\_sys/struct.retro\_game\_info.html](https://docs.rs/rust-libretro-sys/latest/rust_libretro_sys/struct.retro_game_info.html) +28. retroarch \- The reference frontend for the libretro API. \- Ubuntu Manpages, accessed June 28, 2026, [https://manpages.ubuntu.com/manpages/noble/man6/retroarch.6.html](https://manpages.ubuntu.com/manpages/noble/man6/retroarch.6.html) +29. A Continued Discussion Of Gstreamer And Libretro \- GNOME Discourse, accessed June 28, 2026, [https://discourse.gnome.org/t/a-continued-discussion-of-gstreamer-and-libretro/5598](https://discourse.gnome.org/t/a-continued-discussion-of-gstreamer-and-libretro/5598) +30. RETRO\_ENVIRONMENT\_GET\_I, accessed June 28, 2026, [https://docs.rs/rust-libretro-sys/latest/rust\_libretro\_sys/constant.RETRO\_ENVIRONMENT\_GET\_INPUT\_DEVICE\_CAPABILITIES.html](https://docs.rs/rust-libretro-sys/latest/rust_libretro_sys/constant.RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES.html) +31. RetroArch: libretro-common/include/libretro.h File Reference, accessed June 28, 2026, [https://buildbot.libretro.com/doxygen/a04904.html](https://buildbot.libretro.com/doxygen/a04904.html) +32. Retro\_audio\_callback busy waiting \- Development \- Libretro Forums, accessed June 28, 2026, [https://forums.libretro.com/t/retro-audio-callback-busy-waiting/3452](https://forums.libretro.com/t/retro-audio-callback-busy-waiting/3452) +33. libretro audio specs : r/EmuDev \- Reddit, accessed June 28, 2026, [https://www.reddit.com/r/EmuDev/comments/ol55kx/libretro\_audio\_specs/](https://www.reddit.com/r/EmuDev/comments/ol55kx/libretro_audio_specs/) +34. libretro-core-rs/spec/developing-cores.md at main · jefersondaniel, accessed June 28, 2026, [https://github.com/jefersondaniel/libretro-core-rs/blob/main/spec/developing-cores.md](https://github.com/jefersondaniel/libretro-core-rs/blob/main/spec/developing-cores.md) +35. Howto use the audio callback in a core \- Development \- Libretro Forums, accessed June 28, 2026, [https://forums.libretro.com/t/howto-use-the-audio-callback-in-a-core/44866](https://forums.libretro.com/t/howto-use-the-audio-callback-in-a-core/44866) +36. \[CAP32\] broken audio after merge audio-batch-frame-cap · Issue \#13449 · libretro/RetroArch, accessed June 28, 2026, [https://github.com/libretro/RetroArch/issues/13449](https://github.com/libretro/RetroArch/issues/13449) +37. NES cores available on RPi1 are too slow \- Lakka \- Libretro Forums, accessed June 28, 2026, [https://forums.libretro.com/t/nes-cores-available-on-rpi1-are-too-slow/24918](https://forums.libretro.com/t/nes-cores-available-on-rpi1-are-too-slow/24918) +38. retro\_controller\_description in rust\_libretro\_sys \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro-sys/latest/rust\_libretro\_sys/struct.retro\_controller\_description.html](https://docs.rs/rust-libretro-sys/latest/rust_libretro_sys/struct.retro_controller_description.html) +39. v1.3.0 F/G — netplay desync diagnostics \+ niche peripheral aliases, accessed June 28, 2026, [https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27625231407](https://ithub.global.ssl.fastly.net/doublegate/RustyNES/actions/runs/27625231407) +40. Save state manager for RetroPlayer \- GSoC 2020 \- Nick Siakas, accessed June 28, 2026, [https://nikossiak.github.io/blog/gsoc2020.html](https://nikossiak.github.io/blog/gsoc2020.html) +41. network commands \`READ\_CORE\_RAM\` and \`WRITE\_CORE\_RAM\` are broken · Issue \#16392 · libretro/RetroArch \- GitHub, accessed June 28, 2026, [https://github.com/libretro/RetroArch/issues/16392](https://github.com/libretro/RetroArch/issues/16392) +42. Question about retro\_memory\_\* structs \- Development \- Libretro Forums, accessed June 28, 2026, [https://forums.libretro.com/t/question-about-retro-memory--structs/2583](https://forums.libretro.com/t/question-about-retro-memory--structs/2583) +43. How to Create Memory-Mapped Files in Rust \- OneUptime, accessed June 28, 2026, [https://oneuptime.com/blog/post/2026-01-30-how-to-create-memory-mapped-files-in-rust/view](https://oneuptime.com/blog/post/2026-01-30-how-to-create-memory-mapped-files-in-rust/view) +44. Memory mapped files in Rust \- Reddit, accessed June 28, 2026, [https://www.reddit.com/r/rust/comments/sn4zl4/memory\_mapped\_files\_in\_rust/](https://www.reddit.com/r/rust/comments/sn4zl4/memory_mapped_files_in_rust/) +45. hello-rs-libretro — emulator in Rust // Lib.rs, accessed June 28, 2026, [https://lib.rs/crates/hello-rs-libretro](https://lib.rs/crates/hello-rs-libretro) +46. hello-rs-libretro 0.1.1 on Cargo \- Libraries.io \- security, accessed June 28, 2026, [https://libraries.io/cargo/hello-rs-libretro](https://libraries.io/cargo/hello-rs-libretro) +47. 00-INDEX.md \- Gist \- GitHub, accessed June 28, 2026, [https://gist.github.com/winny-/48fbfe2fd5753dc24cfc8bcb37c90199](https://gist.github.com/winny-/48fbfe2fd5753dc24cfc8bcb37c90199) +48. CHANGES.md · master · recalbox / packages / libretro / RetroArch \- GitLab, accessed June 28, 2026, [https://gitlab.com/recalbox/RetroArch/-/blob/master/CHANGES.md](https://gitlab.com/recalbox/RetroArch/-/blob/master/CHANGES.md) +49. rust-libretro-sys — system library interface for Rust // Lib.rs, accessed June 28, 2026, [https://lib.rs/crates/rust-libretro-sys](https://lib.rs/crates/rust-libretro-sys) +50. rust-libretro-example-core \- crates.io: Rust Package Registry, accessed June 28, 2026, [https://crates.io/crates/rust-libretro-example-core](https://crates.io/crates/rust-libretro-example-core) +51. ParaLLEl N64 \- Libretro, accessed June 28, 2026, [https://www.libretro.com/index.php/category/parallel-n64/](https://www.libretro.com/index.php/category/parallel-n64/) +52. set\_subsystem\_info in rust\_libretro::environment \- Rust \- Docs.rs, accessed June 28, 2026, [https://docs.rs/rust-libretro/latest/rust\_libretro/environment/fn.set\_subsystem\_info.html](https://docs.rs/rust-libretro/latest/rust_libretro/environment/fn.set_subsystem_info.html) + +[image1]: + +[image2]: + +[image3]: + +[image4]: + +[image5]: + +[image6]: + +[image7]: + +[image8]: + +[image9]: + +[image10]: + +[image11]: + +[image12]: diff --git a/to-dos/libretro/IMPLEMENTATION_PLAN.md b/to-dos/libretro/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..184271197 --- /dev/null +++ b/to-dos/libretro/IMPLEMENTATION_PLAN.md @@ -0,0 +1,60 @@ +# RustyNES Libretro Core Implementation Plan + +This document outlines the exact execution sequence to build out the `rustynes-libretro` core integration, strictly adhering to the architectural constraints established in `to-dos/libretro/SPRINT_PLAN.md` and `docs/libretro/*`. + + + +## Proposed Changes + +--- + +### Phase 1: Build System & Dependency Wiring + +#### [NEW] [rustynes-libretro/Cargo.toml](crates/rustynes-libretro/Cargo.toml) +Initialize the workspace member crate as a dynamic library. +- Set `crate-type = ["cdylib"]`. +- Link `rustynes-core` via path with `default-features = false`. +- Add `rust-libretro` as the FFI wrapper. + +#### [MODIFY] [Cargo.toml](Cargo.toml) (Root) +- Add `"crates/rustynes-libretro"` to the `[workspace.members]` array. + +--- + +### Phase 2: Core Lifecycle & Dummy Initialization + +#### [NEW] [rustynes-libretro/src/lib.rs](crates/rustynes-libretro/src/lib.rs) +Establish the C ABI FFI boundary. +- Import `rust_libretro::core::{Core, CoreEnvironment, GameInfo}`. +- Define `struct RustyNesLibretro { nes: Option, audio_buffer: Vec }`. +- Implement `Core` for `RustyNesLibretro`. +- Define initialization via `rust_libretro::core_macros::libretro_core!(RustyNesLibretro)`. + +--- + +### Phase 3: ROM Loading, Input, Video, and Audio Math + +#### [MODIFY]- **Core Lifecycle & Architecture Definition:** ([IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md)) +- **Platform Agnostic Build Recipes:** Ensure Makefiles use standard environment variables. +- **Save State Size Guarantee:** Enforce single, static `retro_serialize_size()` on load. +- **`on_run`:** + - **Input:** Translate RetroArch joypad polling into `rustynes_core::Buttons` bitmasks and push to `nes`. + - **Video:** Call `nes.run_frame()` and forward the 256x240 RGBA8 framebuffer via `ctx.draw_frame(buffer, 256, 240, 1024)`. + - **Audio Batching:** Call `nes.drain_audio()`, loop over the `f32` array, execute the scaling calculation `(sample * 65535.0 - 32768.0) as i16`, and push left/right interleaving to the `audio_buffer`. Emit via `ctx.output_audio_slice()`. + +--- + +### Phase 4: Advanced Subsystems (Memory Maps & SRAM) + +#### [MODIFY] [rustynes-libretro/src/lib.rs](crates/rustynes-libretro/src/lib.rs) +- **Direct Memory Maps:** Expose WRAM (`$0000-$07FF`), SRAM (`$6000-$7FFF`), and VRAM (`$2000-$2FFF`) using RetroArch's environment hooks for RetroAchievements zero-cost scanning. +- **Save State Hooks:** Connect `save_state::BinWriter` to `on_serialize` to enable GGPO rollback netplay. + +## Verification Plan + +### Automated Compilation +- I will run `cargo build -p rustynes-libretro --release` to ensure the `cdylib` compiles successfully. +- I will run `cargo test --workspace` to ensure none of the dependencies injected broken `std` references back into the `no_std` core. + +### Manual Verification +- I will ask you (the user) to load the compiled `.so`/`.dll` core in RetroArch along with a test ROM to verify visual output, input polling, and audio synthesis. diff --git a/to-dos/libretro/SPRINT_PLAN.md b/to-dos/libretro/SPRINT_PLAN.md new file mode 100644 index 000000000..a85a19de0 --- /dev/null +++ b/to-dos/libretro/SPRINT_PLAN.md @@ -0,0 +1,193 @@ +# RustyNES Libretro Core Exhaustive Implementation & Integration Sprint Plan + +This document is the absolute source of truth for the Antigravity IDE (and any other developers or autonomous agents) when implementing the `rustynes-libretro` core integration for RetroArch. It expands upon the original architectural blueprint, defining strict code-level constraints, integration mechanisms, mathematics, and file structures. + +This must be followed sequentially. + +--- + +## Phase 1: Build System Bootstrapping & CDYLIB Workspace Configuration + +**Goal:** Initialize the integration crate and configure Cargo for C-ABI exports while preserving the `no_std` nature of the core. + +### 1.1: Crate Instantiation + +* Create a new folder at `crates/rustynes-libretro/`. +* Initialize a standard Cargo library: `cargo init --lib crates/rustynes-libretro`. + +### 1.2: `Cargo.toml` Configuration + +* In `crates/rustynes-libretro/Cargo.toml`, set the crate type to `cdylib` to instruct the compiler to generate a `.so`, `.dll`, or `.dylib`. + + ```toml + [lib] + crate-type = ["cdylib"] + ``` + +### 1.3: Dependencies + +* Add `rustynes-core` as a path dependency. **CRITICAL:** You must declare `default-features = false` to prevent injecting `std` and breaking the core's purity. + + ```toml + [dependencies.rustynes-core] + path = "../rustynes-core" + default-features = false + ``` + +* Add the abstraction libraries `rust-libretro` and `rust-libretro-sys`. + + ```toml + [dependencies] + rust-libretro = "0.1" # (or the latest compatible semantic version) + ``` + +* Add `Cargo.toml` of `rustynes-libretro` to the top-level workspace `Cargo.toml` if it's not automatically included. + +--- + +## Phase 2: Core Lifecycle & Dummy State Negotiation + +**Goal:** Prove FFI boundary stability and RetroArch frontend communication without executing emulation logic. + +### 2.1: The `CoreWrapper` Setup + +* In `crates/rustynes-libretro/src/lib.rs`, import `rust_libretro::core::Core`. +* Create a struct `RustyNesLibretro` that will hold the emulator state: + + ```rust + struct RustyNesLibretro { + nes: Option, + // Buffers for conversion + audio_buffer: Vec, + } + ``` + +* Implement the `rust_libretro::core::Core` trait for `RustyNesLibretro`. + +### 2.2: `retro_get_system_info` + +* Implement `get_system_info()`. +* **library_name:** `"RustyNES"` +* **library_version:** Use `rustynes_core::version()` (e.g., `env!("CARGO_PKG_VERSION")`). +* **valid_extensions:** `"nes|fds"` +* **need_fullpath:** `false` (forces RetroArch to provide the ROM buffer in RAM, avoiding file I/O). +* **block_extract:** `false` + +### 2.3: Environment Handshake (`on_set_environment`) + +* Override the `on_set_environment` method. +* **Pixel Format:** Call the environment's `set_pixel_format(rust_libretro::core::PixelFormat::XRGB8888)`. If this fails, log an error (or fallback to `RGB1555` with a conversion loop, but `XRGB8888` should be universally supported by modern frontends). +* **Input Descriptors:** Construct an array of `retro_input_descriptor` to map RetroPad constants to NES strings: + * `RETRO_DEVICE_ID_JOYPAD_A` -> "NES A Button" + * `RETRO_DEVICE_ID_JOYPAD_B` -> "NES B Button" + * `RETRO_DEVICE_ID_JOYPAD_UP` -> "D-Pad Up" + * etc. + Pass this to `set_input_descriptors()`. + +### 2.4: Compilation & Smoke Test + +* Run `cargo build -p rustynes-libretro --release`. +* Verify that a `.so` (Linux), `.dll` (Windows), or `.dylib` (Mac) is produced in `target/release/`. +* Test via CLI: `retroarch -L target/release/librustynes_libretro.so`. RetroArch should open without crashing and identify the core. + +--- + +## Phase 3: ROM Loading, Geometry, and Synchronous Execution + +**Goal:** Instantiate the `rustynes_core::Nes`, map inputs, render the PPU contiguous framebuffer, and convert audio formats correctly. + +### 3.1: ROM Loading (`on_load_game`) + +* RetroArch passes `game: Option<&rust_libretro::core::GameInfo>`. +* Extract the data: `let data = game.unwrap().data.unwrap();` +* Check if it's an FDS file (by extension or header). For standard `.nes`: + * Initialize the core: `let mut nes = rustynes_core::Nes::from_rom(data).unwrap();` (handle errors gracefully by returning `false` from `on_load_game`). +* Assign `self.nes = Some(nes)`. + +### 3.2: AV Geometry (`get_system_av_info`) + +* **Resolution:** Return base width `256`, height `240`. +* **Max Resolution:** Return width `256`, height `240`. +* **FPS:** Return `60.0988` (NTSC). +* **Sample Rate:** RustyNES defaults to `44100.0` or `48000.0`. You must match the sample rate you request during initialization (use `rustynes_core::bus::DEFAULT_SAMPLE_RATE` or instantiate `Nes::from_rom_with_sample_rate(data, 48000)` and return `48000.0`). + +### 3.3: Input Polling (`on_run`) + +* At the top of `on_run(ctx: &mut rust_libretro::core::RunContext)`, poll the input. +* Query the Joypad state for Port 1. Map `ctx.get_joypad_bit(0, rust_libretro::core::JoypadButton::A)` to `rustynes_core::Buttons::A`, etc. +* Set the input on the `Nes` (likely through `nes.set_controller_state(0, buttons)` -- verify exact `Controller` API in `nes.rs`). + +### 3.4: Frame Execution & Video Refresh (`on_run`) + +* Execute exactly one frame: `let framebuffer = nes.run_frame();`. +* `run_frame()` returns an `&[u8]` RGBA8 buffer of size `256 * 240 * 4 = 245760` bytes. +* Verify the pitch: `width (256) * 4 bytes per pixel = 1024`. +* Pass the video buffer to Libretro: + `ctx.draw_frame(framebuffer, 256, 240, 1024);` +* **Frame Duplication Optimization:** If skipping frames (check frontend fast-forward flags), pass empty buffers appropriately. + +### 3.5: Audio Batch Rendering (`on_run`) + +* RustyNES outputs `f32` audio samples normalized to `[0.0, 1.0]` (or `[-1.0, 1.0]`). +* Call `nes.drain_audio()` or `nes.drain_audio_into(&mut buf)`. +* Libretro mandates interleaved stereo `i16`. +* **Math Conversion Loop:** + + ```rust + self.audio_buffer.clear(); + let f32_samples = nes.drain_audio(); + for &sample in &f32_samples { + // Map f32 [0.0, 1.0] to i16 [-32768, 32767] (adjust if RustyNES outputs [-1, 1]). + // Given bus.rs line 1793: "normalized [0, ~1]" + let scaled = (sample * 65535.0 - 32768.0).clamp(-32768.0, 32767.0) as i16; + // Interleaved stereo: Push Left, Push Right + self.audio_buffer.push(scaled); + self.audio_buffer.push(scaled); + } + ctx.output_audio_slice(&self.audio_buffer); + ``` + +--- + +## Phase 4: Advanced Subsystems (RetroAchievements, GGPO, VFS) + +**Goal:** Deliver preservation-grade integration by exposing memory pointers and deterministic serialization logic. + +### 4.1: Direct Memory Mapping (RetroAchievements) + +* Implement `RETRO_ENVIRONMENT_SET_MEMORY_MAPS`. +* `rustynes-core` has getters for WRAM and SRAM (e.g., `nes.wram_ptr()` or similar). +* Expose these directly using `retro_memory_descriptor`. +* Mappings: + * WRAM: `$0000 - $07FF`, Flag: `RETRO_MEMDESC_SYSTEM_RAM` + * SRAM: `$6000 - $7FFF`, Flag: `RETRO_MEMDESC_SAVE_RAM` + * VRAM: `$2000 - $2FFF`, Flag: `RETRO_MEMDESC_VIDEO_RAM` +* This guarantees zero-overhead `rcheevos` network tracking. + +### 4.2: Save RAM File Offloading (SRAM/Battery) + +* Implement `on_get_memory_data` and `on_get_memory_size`. +* When the frontend requests `RETRO_MEMORY_SAVE_RAM`, return a mutable pointer to the SRAM. +* RetroArch will automatically read/write `.srm` files to the disk on shutdown/startup, saving you from dealing with `std::fs` operations. + +### 4.3: Deterministic Serialization (GGPO & TAS) + +* Expose RustyNES's `save_state` system via Libretro's serialization hooks: + * `on_serialize_size()`: Return a strict, unchanging `usize` buffer size. + * `on_serialize(buffer: &mut [u8])`: Delegate to `rustynes_core::save_state::BinWriter` to serialize the emulator state. + * `on_unserialize(buffer: &[u8])`: Delegate to `rustynes_core::save_state::BinReader` to restore. +* **CRITICAL:** `serialize_size` must never change once `on_load_game` is finished, or GGPO rollback instances will fault. + +### 4.4: Famicom Disk System (VFS / BIOS Handling) + +* Implement Libretro subsystem hooks for `.fds` handling. +* If a game is loaded via the `FDS` subsystem, use Libretro's VFS (Virtual File System) callbacks to securely locate and load the `disksys.rom` BIOS without using `std::fs`. +* Delegate to `Nes::from_disk(disk_bytes, bios_bytes)` inside the modified loader. + +--- + +## Conclusion & Code Standards + +* **NO `std` in CORE:** Under no circumstances should `rustynes-libretro` modifications leak standard library usage into `rustynes-core`. +* **Zero Allocations in `on_run`:** Do not use `vec!` or `String` allocations inside the `on_run` cycle to maintain optimal performance and prevent micro-stutters during execution. Reuse `self.audio_buffer`. +* **Error Handling:** Gracefully fail `on_load_game` using `false` if `RomError` is returned; never `panic!()`.