From 36a755375134ed748e786c5459d7435b3194f4e8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 19 Jul 2026 17:40:55 -0400 Subject: [PATCH 1/3] feat(libretro): add buildbot CI recipe and complete core feature set Adds the .gitlab-ci.yml the libretro/RetroArch team requested in #311 to get RustyNES onto RetroArch's built-in core downloader, and closes out the concrete feature gaps found while researching it. Buildbot recipe (crates/rustynes-libretro): - .gitlab-ci.yml: Windows x64, Linux x64, macOS x64/arm64, Android (4 ABIs), iOS arm64, tvOS arm64 via the shared libretro-infrastructure/ ci-templates includes. Excludes webOS (no reference rust core uses it, crate Makefile has no webos branch) and Nintendo Switch/libnx (no rust-libnx*.yml template exists upstream at all) as unproven for a first submission, per the issue's "only include platforms that will build successfully". - The shared templates run a FIXED `cargo build --release --target ` with no `-p` flag, then `mv lib${CORENAME}. ${CORENAME}_libretro.`. Two fixes were required for this to actually work: - `[lib] name = "rustynes"` in the crate's Cargo.toml (previously unset, defaulting to `rustynes_libretro` from the package name) -- without this, CORENAME=rustynes_libretro would double the `_libretro` suffix in the final artifact name, and CORENAME=rustynes would look for a source file that doesn't exist. The crate's own Makefile is updated to match (copies from the new `rustynes.` source name into the unchanged `rustynes_libretro.` final name). - `[workspace] default-members = ["crates/rustynes-libretro"]` in the root Cargo.toml, so the templates' unscoped `cargo build` builds only this crate instead of all 18 workspace members (in particular the frontend's wgpu/winit/cpal/wayland stack, which has no business on a headless cross-compile buildbot image). Confirmed safe: every CI workflow, this crate's Makefile, and every scripts/*.sh invocation already scopes with `-p`/ `--workspace`/`--manifest-path`. Fixed the 4 documentation snippets that showed a bare `cargo build` (docs/dev/CONTRIBUTING.md, SUPPORT.md x2, docs/dev/DEBUGGING.md) so copy-pasting them still builds the whole workspace/frontend as intended. - Also fixes the crate Makefile's macOS cross-target gap (the `osx` platform branch set no RUST_TARGET at all) by adding explicit x86_64-apple-darwin/aarch64-apple-darwin triples keyed off ARCH, falling back to native compile when ARCH is unset (unchanged local `make` behavior on macOS). Core feature completion (crates/rustynes-libretro/src/lib.rs): - RETRO_ENVIRONMENT_SET_MEMORY_MAPS: registers WRAM/SRAM/VRAM descriptors via a new `register_memory_maps` call at the end of `on_load_game` -- the memory-descriptor path RetroAchievements' rcheevos prefers over the legacy pointer API (kept unchanged alongside it, since RetroArch's own .srm persistence goes through it regardless). - FDS load-path fix: `.fds` content was previously always routed through `Emu::from_rom` (cartridge-only), so FDS loading silently failed despite `valid_extensions` advertising it. Now detected via the extension libretro's GET_GAME_INFO_EXT reports and routed to `Nes::from_disk` with a `disksys.rom` lookup in the frontend's system directory. - Disk-control interface: on_set_eject_state/on_get_eject_state/ on_get_image_index/on_set_image_index/on_get_num_images/ on_get_image_path/on_get_image_label, backed by the existing Nes::disk_side_count/inserted_disk_side/set_disk_side API (the same one the desktop frontend's F9 keybind uses), registered via enable_disk_control_interface() -- surfaces FDS multi-side swap in RetroArch's Quick Menu. - Native Game Genie cheats: on_cheat_set/on_cheat_reset backed by Nes::add_genie_code/remove_genie_code/clear_genie_codes (already excluded from serialized state, so no determinism impact). - get_fastforwarding-gated audio skip in run_single/run_dual: skips the f32->i16 interleave + batch_audio_samples push while RetroArch is fast-forwarding/rollback-catching-up. A modest, honest win -- rustynes-core has no mixer-bypass API, so APU synthesis itself (the dominant cost) isn't skipped. - `rust-libretro`'s `unstable-env-commands` feature is now enabled (required for set_memory_maps/get_fastforwarding; disk-control and cheats were already available without it). - Investigated but explicitly NOT attempted: a region/timing (NTSC/ PAL/Dendy) core option. rustynes-core parses and caches region into fixed CPU/PPU dividers once at construction with no post-hoc setter (a deliberate hot-path optimization per bus.rs's own comment) -- adding this needs real rustynes-core construction surgery, not libretro-side wiring, so it's left for a future, separately-scoped change. Also deferred: Zapper/Power Pad peripheral wiring (RETRO_ENVIRONMENT_SET_CONTROLLER_INFO) -- confirmed feasible (rustynes-core already exposes Nes::set_zapper/set_power_pad) but needs substantially more new surface (a controller-info table, on_set_controller_port_device, new lightgun/analog input polling) than this change's scope. None of the feature-completion items touch CPU/PPU/APU emulation logic or on_serialize/on_unserialize -- all are additive FFI/ presentation-layer wiring over already-existing, already-tested rustynes-core APIs, so AccuracyCoin/golden-vector determinism is unaffected by design. docs/libretro/advanced_features.md updated to describe what's actually implemented (the memory-maps/fastforwarding/FDS/cheats sections previously described aspirational designs, including an FDS RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO approach that was considered but not built in favor of the simpler system-directory BIOS lookup that shipped instead). Companion upstream PRs (per docs/libretro/UPSTREAM_SYNC.md): libretro/docs#1164, libretro/libretro-super#2021. Closes #311. --- .gitlab-ci.yml | 117 ++++++++++ CHANGELOG.md | 22 ++ Cargo.toml | 11 + SUPPORT.md | 6 +- crates/rustynes-libretro/Cargo.toml | 17 +- crates/rustynes-libretro/Makefile | 15 +- crates/rustynes-libretro/src/lib.rs | 317 +++++++++++++++++++++++++--- docs/dev/CONTRIBUTING.md | 4 +- docs/dev/DEBUGGING.md | 2 +- docs/libretro/advanced_features.md | 22 +- 10 files changed, 490 insertions(+), 43 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..79e15934 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,117 @@ +# RustyNES libretro core — buildbot recipe. +# +# Populates RetroArch's built-in core downloader via the shared libretro CI +# templates (git.libretro.com/libretro-infrastructure/ci-templates). Added +# per https://github.com/doublegate/RustyNES/issues/311. +# +# `CORENAME` is `rustynes`, NOT `rustynes_libretro` or `rustynes-libretro`: +# these templates always run a fixed `mv lib${CORENAME}. +# ${CORENAME}_libretro.` step. `crates/rustynes-libretro/Cargo.toml` +# sets `[lib] name = "rustynes"` explicitly (its `[package] name` stays +# `rustynes-libretro` for `-p rustynes-libretro` scoping everywhere else), +# so `CORENAME=rustynes` makes both the compiled-artifact name and the +# final packaged name resolve correctly to `rustynes_libretro.` — +# matching the already-published `rustynes_libretro.info`. +# +# `cargo build --release --target ` here runs from the repo root +# with no `-p` flag (the template scripts are centrally maintained and not +# per-core editable); the root `Cargo.toml`'s `[workspace] default-members = +# ["crates/rustynes-libretro"]` is what scopes that bare command to just +# this crate instead of all 18 workspace members. +# +# Platform scope: mirrors the two proven, currently-building reference +# cores (doukutsu-rs-libretro, holani-retro) minus platforms we have +# concrete evidence won't build — no `rust-libnx*.yml` template exists in +# ci-templates (Nintendo Switch is not buildable this way for a Rust core +# today), and no reference core includes webOS. Both can be added later +# once the basics are proven on the buildbot. + +.core-defs: + variables: + CORENAME: rustynes + +include: + ################################## DESKTOPS ################################ + # Windows 64-bit + - project: 'libretro-infrastructure/ci-templates' + file: '/rust-windows-x64.yml' + + # Linux 64-bit + - project: 'libretro-infrastructure/ci-templates' + file: '/rust-linux-x64.yml' + + # macOS/iOS/tvOS + - project: 'libretro-infrastructure/ci-templates' + file: '/rust-apple.yml' + + ################################## CELLULAR ################################ + # Android + - project: 'libretro-infrastructure/ci-templates' + file: '/rust-android-jni.yml' + +stages: + - build-prepare + - build-shared + - build-static + +################################### DESKTOPS ################################# +# Windows 64-bit +libretro-build-windows-x64: + extends: + - .libretro-rust-windows-x64-default + - .core-defs + +# Linux 64-bit +libretro-build-linux-x64: + extends: + - .libretro-rust-linux-x64-default + - .core-defs + +# macOS 64-bit +libretro-build-osx-x64: + extends: + - .libretro-rust-osx-x86_64-default + - .core-defs + +# macOS ARM 64-bit +libretro-build-osx-arm64: + extends: + - .libretro-rust-osx-arm64-default + - .core-defs + +################################### CELLULAR ################################# +# Android ARMv7a +android-armeabi-v7a: + extends: + - .libretro-rust-android-jni-armeabi-v7a + - .core-defs + +# Android ARMv8a +android-arm64-v8a: + extends: + - .libretro-rust-android-jni-arm64-v8a + - .core-defs + +# Android 64-bit x86 +android-x86_64: + extends: + - .libretro-rust-android-jni-x86_64 + - .core-defs + +# Android 32-bit x86 +android-x86: + extends: + - .libretro-rust-android-jni-x86 + - .core-defs + +# iOS +libretro-build-ios-arm64: + extends: + - .libretro-rust-ios-arm64-default + - .core-defs + +# tvOS +libretro-build-tvos-arm64: + extends: + - .libretro-rust-tvos-arm64-default + - .core-defs diff --git a/CHANGELOG.md b/CHANGELOG.md index 53cc332b..1ff5184d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,28 @@ cycle-accurate core later replaced. ## [Unreleased] +### Added + +- Libretro buildbot CI recipe (`.gitlab-ci.yml`, issue #311) covering Windows + x64, Linux x64, macOS x64/arm64, Android (4 ABIs), iOS arm64, and tvOS + arm64 — the missing piece to get RustyNES onto RetroArch's built-in core + downloader (the repo was already integrated with the legacy + `libretro-super` scripts). Paired with a `[workspace] default-members` + fix so the templates' unscoped `cargo build --release --target ` + builds only `crates/rustynes-libretro`, and a `[lib] name = "rustynes"` + override resolving a compiled-artifact naming collision with the shared + CI templates' fixed `${CORENAME}_libretro` convention. +- Libretro core feature completion: native `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` + registration (the memory-descriptor path RetroAchievements' `rcheevos` + prefers, alongside the existing legacy pointer API); an FDS load-path fix + (`.fds` content is now correctly routed to `Nes::from_disk` with a + `disksys.rom` lookup in the frontend's system directory — previously + broken despite `valid_extensions` advertising it) plus a full disk-control + interface for FDS multi-side swapping via RetroArch's Quick Menu; native + Game Genie cheat support (`on_cheat_set`/`on_cheat_reset`); and a + `get_fastforwarding`-gated audio-push skip during RetroArch's + fast-forward/rollback-netplay catch-up path. + ## [2.2.1] - 2026-07-15 - Housekeeping patch (dev-tooling archival + dependency consolidation + FDS test corpus) Zero accuracy, feature, or core changes — the deterministic `#![no_std]` chip diff --git a/Cargo.toml b/Cargo.toml index 08d52d29..08b318c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,17 @@ members = [ "crates/rustynes-ra", "crates/rustynes-monetization", "crates/rustynes-libretro", ] +# The libretro buildbot's shared CI templates run a bare, unscoped +# `cargo build --release --target ` from the repo root (no `-p`, +# no `--manifest-path` — the script is centrally maintained and can't be +# edited per-core). Scoping the default member to just the libretro crate +# keeps that command building ONLY `rustynes-libretro`, not all 18 workspace +# members (in particular `rustynes-frontend`, which needs wgpu/winit/cpal/ +# wayland system libs the buildbot's headless cross-compile image doesn't +# have). This does not change any `--workspace`/`-p `-scoped command +# — every CI workflow, this crate's own Makefile, and every scripts/*.sh +# invocation already scopes explicitly. See `.gitlab-ci.yml`. +default-members = ["crates/rustynes-libretro"] [workspace.package] version = "2.2.1" diff --git a/SUPPORT.md b/SUPPORT.md index 7ae7462f..ad91d8ca 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -21,7 +21,7 @@ Thank you for using RustyNES! This document provides guidance on how to get help ```bash git pull origin main - cargo build --release + cargo build --release --workspace ``` --- @@ -135,7 +135,7 @@ A: See [docs/dev/BUILD.md](docs/dev/BUILD.md) for detailed build instructions. Q ```bash git clone https://github.com/doublegate/RustyNES.git cd RustyNES -cargo build --release +cargo build --release --workspace ``` **Q: What are the prerequisites?** @@ -148,7 +148,7 @@ A: 1. Ensure you have Rust 1.86 or newer: `rustc --version` 2. Install the frontend system libraries (see [BUILD.md](docs/dev/BUILD.md)) -3. Try a clean build: `cargo clean && cargo build` +3. Try a clean build: `cargo clean && cargo build --workspace` 4. Check [GitHub Issues](https://github.com/doublegate/RustyNES/issues) for known build problems 5. Ask for help in [Discussions](https://github.com/doublegate/RustyNES/discussions) diff --git a/crates/rustynes-libretro/Cargo.toml b/crates/rustynes-libretro/Cargo.toml index 8eeb85eb..46481ec4 100644 --- a/crates/rustynes-libretro/Cargo.toml +++ b/crates/rustynes-libretro/Cargo.toml @@ -11,6 +11,17 @@ keywords = ["nes", "emulator", "famicom", "libretro", "retroarch"] categories = ["emulators"] [lib] +# Explicit override so the compiled artifact is `librustynes.{so,dylib,a}` / +# `rustynes.dll` rather than the package-name default `rustynes_libretro`. +# The libretro buildbot's shared CI templates (`libretro-infrastructure/ +# ci-templates`) run a fixed `mv lib${CORENAME}.so ${CORENAME}_libretro.so` +# step, so the compiled lib name must NOT already contain `_libretro` or the +# final artifact name would double up (`rustynes_libretro_libretro.so`). +# `CORENAME=rustynes` in `.gitlab-ci.yml` pairs with this to produce the +# correct `rustynes_libretro.*` name both CI paths (this and the legacy +# libretro-super Makefile) ultimately require. See `.gitlab-ci.yml` and +# `Makefile` in this crate. +name = "rustynes" crate-type = ["cdylib", "staticlib"] [lints] @@ -18,7 +29,11 @@ workspace = true [dependencies] libc = "0.2.186" -rust-libretro = "0.3.2" +# `unstable-env-commands` unlocks `set_memory_maps` (RetroAchievements' +# preferred memory-descriptor path) and `get_fastforwarding` (used to skip +# non-essential audio work during RetroArch's rollback-netplay/fast-forward +# fast path) — both are compiled out of `rust-libretro` without this feature. +rust-libretro = { version = "0.3.2", features = ["unstable-env-commands"] } [dependencies.rustynes-core] path = "../rustynes-core" diff --git a/crates/rustynes-libretro/Makefile b/crates/rustynes-libretro/Makefile index 8491868b..a5764fb1 100644 --- a/crates/rustynes-libretro/Makefile +++ b/crates/rustynes-libretro/Makefile @@ -26,6 +26,17 @@ ifeq ($(platform),android) else ifeq ($(ARCH),x86) RUST_TARGET := i686-linux-android endif +else ifeq ($(platform),osx) + # Only force an explicit cross-target when the buildbot tells us the arch + # (ARCH set); a bare local `make` on macOS with no ARCH falls through to + # native compile (empty RUST_TARGET), same as before this branch existed. + ifeq ($(ARCH),arm64) + RUST_TARGET := aarch64-apple-darwin + else ifeq ($(ARCH),aarch64) + RUST_TARGET := aarch64-apple-darwin + else ifeq ($(ARCH),x86_64) + RUST_TARGET := x86_64-apple-darwin + endif else ifeq ($(platform),ios) RUST_TARGET := aarch64-apple-ios else ifeq ($(platform),tvos) @@ -94,12 +105,12 @@ all: release build: $(CARGO_CMD) - cp $(OUT_DIR)/debug/$(PREFIX)rustynes_libretro.$(EXT) rustynes_libretro.$(EXT) + cp $(OUT_DIR)/debug/$(PREFIX)rustynes.$(EXT) rustynes_libretro.$(EXT) @echo "Copied debug core to rustynes_libretro.$(EXT)" release: $(CARGO_CMD) --release - cp $(OUT_DIR)/release/$(PREFIX)rustynes_libretro.$(EXT) rustynes_libretro.$(EXT) + cp $(OUT_DIR)/release/$(PREFIX)rustynes.$(EXT) rustynes_libretro.$(EXT) @echo "Copied release core to rustynes_libretro.$(EXT)" clean: diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index db82ac04..fd841723 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -21,9 +21,21 @@ //! For a `DualSystem` cabinet libretro ports 0/1 drive the MAIN console's P1/P2 and //! ports 2/3 drive the SUB console's P1/P2 (matching `VsDualSystem::set_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` (single console) or -//! `VsDualSystem::snapshot` (dual cabinet). +//! safely by isolating the memory accessors in the core, exposed both via the legacy +//! `retro_get_memory_data`/`_size` pointer API and the modern +//! `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` descriptor registration RetroAchievements' +//! `rcheevos` prefers. Save states serialize statically sized binary blobs natively +//! through `Nes::snapshot_core_into` (single console) or `VsDualSystem::snapshot` +//! (dual cabinet) — this is also what RetroArch's own generic rollback netplay and +//! movie/rewind features ride on; RustyNES's bespoke `rustynes-netplay` P2P crate is +//! intentionally NOT linked into this core, since it would be redundant with (and +//! conflict with) RetroArch's own equivalent systems. +//! - **FDS**: `.fds` disk images are routed to [`rustynes_core::Nes::from_disk`] (looking +//! up `disksys.rom` in the frontend's system directory), and multi-side disk swapping is +//! exposed through libretro's disk-control interface (`on_set_eject_state` et al.). +//! - **Cheats**: Native Game Genie code application via `on_cheat_set`/`on_cheat_reset`, +//! backed by [`rustynes_core::Nes::add_genie_code`] (excluded from serialized state, so +//! it never affects save-state/netplay/TAS determinism). //! //! # Vs. `DualSystem` present path (v2.1.10 "Web Parity") //! @@ -53,7 +65,8 @@ use rust_libretro::{ types::*, }; use rustynes_core::{Emu, Nes, VsDualSystem}; -use std::ffi::CString; +use std::collections::BTreeMap; +use std::ffi::{CStr, CString}; /// NES native framebuffer width in pixels (one console). const NES_W: usize = 256; @@ -106,6 +119,12 @@ pub struct RustyNesLibretro { /// Pre-allocated buffer for snapshot serialization. serialize_buffer: Vec, + + /// Active Game Genie codes, keyed by the frontend's per-slot cheat index + /// (`on_cheat_set`'s `index`). Deliberately NOT part of save-state / + /// serialized state, matching `Nes::add_genie_code`'s own contract, so + /// cheats never affect the deterministic core or netplay/TAS replay. + genie_cheats: BTreeMap, } impl Default for RustyNesLibretro { @@ -124,6 +143,7 @@ impl Default for RustyNesLibretro { video_buffer: Vec::with_capacity(DUAL_W * NES_H * 4), serialize_size: 0, serialize_buffer: Vec::new(), + genie_cheats: BTreeMap::new(), } } } @@ -195,6 +215,87 @@ fn blit_scanline_rgba_to_xrgb(dst: &mut [u8], src: &[u8]) { } impl RustyNesLibretro { + /// The single `Nes` that RetroAchievements / cheats / disk-control / memory-maps + /// target: the single-console instance, or the MAIN console of a loaded Vs. + /// `DualSystem` cabinet. Factored out of `get_memory_data`/`get_memory_size` (which + /// duplicated this match) and shared by `register_memory_maps`, the disk-control + /// hooks, and the cheat hooks — all of which use the same MAIN-console convention. + fn active_nes_mut(&mut self) -> Option<&mut Nes> { + match (self.nes.as_mut(), self.dual.as_mut()) { + (Some(nes), _) => Some(nes), + (None, Some(dual)) => Some(dual.main_mut()), + (None, None) => None, + } + } + + /// Shared-reference counterpart of [`Self::active_nes_mut`]. + fn active_nes(&self) -> Option<&Nes> { + match (self.nes.as_ref(), self.dual.as_ref()) { + (Some(nes), _) => Some(nes), + (None, Some(dual)) => Some(dual.main()), + (None, None) => None, + } + } + + /// Register `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` descriptors for WRAM, PRG-RAM/SRAM + /// (when battery-backed), and PPU nametable VRAM. This is the memory-inspection path + /// RetroAchievements' `rcheevos` prefers over the legacy `get_memory_data`/`_size` + /// pointer API (kept below, unchanged, since RetroArch's own `.srm` persistence goes + /// through it regardless — this is additive, not a replacement). The descriptor + /// pointers are the SAME fixed-size, constructed-once allocations the legacy path + /// already exposes, so reusing them here is exactly as safe. + fn register_memory_maps(&mut self, ctx: &mut LoadGameContext) { + let Some(nes) = self.active_nes_mut() else { + return; + }; + let mut descriptors = Vec::with_capacity(3); + descriptors.push(retro_memory_descriptor { + flags: u64::from(RETRO_MEMDESC_SYSTEM_RAM), + ptr: nes.wram_mut().as_mut_ptr().cast::(), + offset: 0, + start: 0x0000, + select: 0, + disconnect: 0, + len: nes.wram_mut().len(), + addrspace: std::ptr::null(), + }); + let sram_len = nes.sram_mut().len(); + if sram_len > 0 { + descriptors.push(retro_memory_descriptor { + flags: u64::from(RETRO_MEMDESC_SAVE_RAM), + ptr: nes.sram_mut().as_mut_ptr().cast::(), + offset: 0, + start: 0x6000, + select: 0, + disconnect: 0, + len: sram_len, + addrspace: std::ptr::null(), + }); + } + descriptors.push(retro_memory_descriptor { + flags: u64::from(RETRO_MEMDESC_VIDEO_RAM), + ptr: nes.vram_mut().as_mut_ptr().cast::(), + offset: 0, + start: 0x2000, + select: 0, + disconnect: 0, + len: nes.vram_mut().len(), + addrspace: std::ptr::null(), + }); + let map = retro_memory_map { + descriptors: descriptors.as_ptr(), + num_descriptors: descriptors.len() as std::os::raw::c_uint, + }; + // SAFETY: `ctx` carries a valid environment callback for the duration of + // `on_load_game` (guaranteed by the libretro spec); the frontend copies + // `descriptors[0..num_descriptors]` synchronously before this call returns + // (standard `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` semantics), so `descriptors` + // only needs to outlive this call, not `self`. + unsafe { + ctx.set_memory_maps(map); + } + } + /// Convert `produced` mono `f32` samples from `audio_float_buffer` into /// interleaved stereo `i16` and push them. Shared by both present paths so the /// audio scaling / interleave logic lives in exactly one place. The buffer is @@ -218,6 +319,22 @@ impl RustyNesLibretro { .batch_audio_samples(&self.audio_buffer); } + /// Whether RetroArch is currently fast-forwarding (either user-requested or the + /// rollback-netplay/GGPO catch-up path). The emulation clock itself can't be + /// cheaply skipped (no mixer-bypass exists in `rustynes-core`), but the per-frame + /// `f32`→`i16` interleave + `batch_audio_samples` FFI push is pure presentation + /// overhead that's safe to skip while fast-forwarding — a modest, safe win, not a + /// large perf change (APU synthesis inside `run_frame()` dominates and isn't + /// touched by this). + fn is_fastforwarding(ctx: &mut RunContext) -> bool { + // SAFETY: `ctx` carries a valid environment callback for the duration of + // `on_run` (guaranteed by the libretro spec). + unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + generic_ctx.get_fastforwarding() + } + } + /// The classic single-console present path: 256x240 XRGB8888 + one audio stream. fn run_single(&mut self, ctx: &mut RunContext) { ctx.poll_input(); @@ -248,7 +365,9 @@ impl RustyNesLibretro { .as_mut() .expect("nes present") .drain_audio_into(&mut self.audio_float_buffer); - self.push_audio(ctx, produced); + if !Self::is_fastforwarding(ctx) { + self.push_audio(ctx, produced); + } } /// The Vs. `DualSystem` present path: step BOTH consoles, compose their two @@ -283,7 +402,9 @@ impl RustyNesLibretro { .expect("dual present") .main_mut() .drain_audio_into(&mut self.audio_float_buffer); - self.push_audio(ctx, produced); + if !Self::is_fastforwarding(ctx) { + self.push_audio(ctx, produced); + } // Bound the SUB console's APU buffer even though its audio is not played: // drain it into a small stack scratch, looping until a partial fill signals @@ -373,6 +494,11 @@ impl Core for RustyNesLibretro { { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_RIGHT, "D-Pad Right" } ); rust_libretro::environment::set_input_descriptors(cb, &descriptors); + + // Register the disk-control callback trampolines (on_set_eject_state, + // on_get_image_index, etc. below) so RetroArch's Quick Menu → Disk + // Control surfaces FDS multi-side swapping. + generic_ctx.enable_disk_control_interface(); } } @@ -417,17 +543,52 @@ impl Core for RustyNesLibretro { slice.to_vec() }; - // `Emu::from_rom` picks the right shape for the cart: a `VsDualSystem` for the - // four Vs. DualSystem boards (detected via the NES 2.0 header Vs. type OR the - // SHA-keyed `vs_db`), else a standard single `Nes`. This is the SAME detection - // the desktop frontend uses, so the libretro core presents dual cabinets - // identically (two consoles side-by-side) instead of booting a single console - // that would hang waiting on its cross-wired partner. - let emu = match Emu::from_rom(&rom_data) { - Ok(e) => e, - Err(e) => { - eprintln!("[RustyNES] Failed to parse ROM: {e:?}"); - return Err(format!("Failed to load ROM: {e:?}").into()); + // `ext_info.ext` reflects the original file extension even when the ROM was + // handed to us as an in-memory buffer (need_fullpath is false). Route `.fds` + // disk images to the dedicated FDS constructor: `Emu::from_rom` only parses + // iNES/NES 2.0 cartridge headers, so without this branch FDS loading silently + // fails despite `valid_extensions` advertising it. + // SAFETY: `ext_info.ext` is either null or a valid, NUL-terminated, + // frontend-owned C string for the duration of this call, per the same + // `RetroGameInfoExt` contract already relied on above for `data`/`size`. + let is_fds = (!ext_info.ext.is_null()) + && unsafe { CStr::from_ptr(ext_info.ext) } + .to_str() + .is_ok_and(|ext| ext.eq_ignore_ascii_case("fds")); + + let emu = if is_fds { + let generic_ctx: GenericContext = (&*ctx).into(); + let bios_dir = generic_ctx + .get_system_directory() + .ok_or("Frontend did not provide a system directory for the FDS BIOS")?; + let bios_path = bios_dir.join("disksys.rom"); + let bios = std::fs::read(&bios_path).map_err(|e| { + format!( + "Missing FDS BIOS at {}: {e} (RustyNES needs disksys.rom in the \ + frontend's system directory to boot Famicom Disk System games)", + bios_path.display() + ) + })?; + match Nes::from_disk(&rom_data, &bios) { + Ok(nes) => Emu::Single(Box::new(nes)), + Err(e) => { + eprintln!("[RustyNES] Failed to parse FDS disk image: {e:?}"); + return Err(format!("Failed to load FDS disk: {e:?}").into()); + } + } + } else { + // `Emu::from_rom` picks the right shape for the cart: a `VsDualSystem` for + // the four Vs. DualSystem boards (detected via the NES 2.0 header Vs. type OR + // the SHA-keyed `vs_db`), else a standard single `Nes`. This is the SAME + // detection the desktop frontend uses, so the libretro core presents dual + // cabinets identically (two consoles side-by-side) instead of booting a + // single console that would hang waiting on its cross-wired partner. + match Emu::from_rom(&rom_data) { + Ok(e) => e, + Err(e) => { + eprintln!("[RustyNES] Failed to parse ROM: {e:?}"); + return Err(format!("Failed to load ROM: {e:?}").into()); + } } }; @@ -454,6 +615,7 @@ impl Core for RustyNesLibretro { eprintln!("[RustyNES] Loaded Vs. DualSystem cabinet (512x240 side-by-side)."); } } + self.register_memory_maps(ctx); Ok(()) } @@ -477,11 +639,12 @@ impl Core for RustyNesLibretro { // Memory boundary enforcement remains safe within the `rustynes_core` design. // In dual mode the MAIN console is the one RetroAchievements / cheats target // (its memory map is where gameplay state lives); expose it, matching the - // single-console mapping. `main_mut()` yields the MAIN `Nes`. - let nes = match (self.nes.as_mut(), self.dual.as_mut()) { - (Some(nes), _) => nes, - (None, Some(dual)) => dual.main_mut(), - (None, None) => return std::ptr::null_mut(), + // single-console mapping (see `Self::active_nes_mut`). Kept alongside the + // richer `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` registration (`register_memory_maps`) + // since RetroArch's own `.srm` persistence goes through this legacy path + // regardless. + let Some(nes) = self.active_nes_mut() else { + return std::ptr::null_mut(); }; match id { RETRO_MEMORY_SAVE_RAM => { @@ -504,10 +667,8 @@ impl Core for RustyNesLibretro { _ctx: &mut GetMemorySizeContext, ) -> usize { // Mirror `get_memory_data`: the MAIN console in dual mode. - let nes = match (self.nes.as_ref(), self.dual.as_ref()) { - (Some(nes), _) => nes, - (None, Some(dual)) => dual.main(), - (None, None) => return 0, + let Some(nes) = self.active_nes() else { + return 0; }; match id { RETRO_MEMORY_SAVE_RAM => nes.sram().len(), @@ -550,6 +711,109 @@ impl Core for RustyNesLibretro { } false } + + // --- Disk control (FDS multi-side swap) --------------------------------------- + // + // Backed entirely by `Nes::disk_side_count`/`inserted_disk_side`/`set_disk_side` + // (the same API the desktop frontend's F9 disk-swap keybind uses) — no cartridge + // build ever reports more than 0 sides, so these are no-ops outside FDS. Callback + // trampolines are registered once via `enable_disk_control_interface()` in + // `on_set_environment`. + + fn on_set_eject_state(&mut self, ejected: bool) -> bool { + let Some(nes) = self.active_nes_mut() else { + return false; + }; + if ejected { + nes.set_disk_side(None); + } else { + // Re-insert whichever side was last active, defaulting to side 0 (Side A) + // if the disk had never been inserted this session. + let side = nes.inserted_disk_side().unwrap_or(0); + nes.set_disk_side(Some(side)); + } + true + } + + fn on_get_eject_state(&mut self) -> bool { + self.active_nes_mut() + .is_some_and(|nes| nes.inserted_disk_side().is_none()) + } + + fn on_get_image_index(&mut self) -> u32 { + self.active_nes() + .and_then(Nes::inserted_disk_side) + .map_or(0, |i| i as u32) + } + + fn on_set_image_index(&mut self, index: u32) -> bool { + let Some(nes) = self.active_nes_mut() else { + return false; + }; + if (index as usize) >= nes.disk_side_count() { + return false; + } + nes.set_disk_side(Some(index as usize)); + true + } + + fn on_get_num_images(&mut self) -> u32 { + self.active_nes_mut() + .map_or(0, |nes| nes.disk_side_count() as u32) + } + + fn on_get_image_path(&mut self, _index: u32) -> Option { + // No real per-side file paths exist for a single multi-side `.fds` container. + None + } + + fn on_get_image_label(&mut self, index: u32) -> Option { + // Synthesize "Side A" / "Side B" / ... labels for the Quick Menu. + let letter = char::from(b'A' + u8::try_from(index).ok()?); + CString::new(format!("Side {letter}")).ok() + } + + // --- Cheats (native Game Genie) ------------------------------------------------ + // + // Backed by `Nes::add_genie_code`/`remove_genie_code`/`clear_genie_codes`, which + // are deliberately excluded from serialized state, so cheats never affect + // save-state / netplay / TAS determinism. `genie_cheats` just remembers which + // code string was applied at each frontend-assigned slot `index`, since + // `on_cheat_set` only tells us the code being toggled, not what was there before. + + fn on_cheat_set( + &mut self, + index: std::os::raw::c_uint, + enabled: bool, + code: &CStr, + _ctx: &mut CheatSetContext, + ) { + let Ok(code_str) = code.to_str() else { + eprintln!("[RustyNES] Ignoring non-UTF8 cheat code at index {index}."); + return; + }; + if enabled { + let applied = self + .active_nes_mut() + .is_some_and(|nes| nes.add_genie_code(code_str).is_ok()); + if applied { + self.genie_cheats.insert(index, code_str.to_owned()); + } else { + eprintln!("[RustyNES] Rejected Game Genie code {code_str:?} at index {index}."); + } + } else if let Some(old_code) = self.genie_cheats.remove(&index) + && let Some(nes) = self.active_nes_mut() + { + nes.remove_genie_code(&old_code); + } + } + + fn on_cheat_reset(&mut self, _ctx: &mut CheatResetContext) { + self.genie_cheats.clear(); + if let Some(nes) = self.active_nes_mut() { + nes.clear_genie_codes(); + } + } } retro_core!(RustyNesLibretro { @@ -560,4 +824,5 @@ retro_core!(RustyNesLibretro { video_buffer: Vec::with_capacity(DUAL_W * NES_H * 4), serialize_size: 0, serialize_buffer: Vec::new(), + genie_cheats: BTreeMap::new(), }); diff --git a/docs/dev/CONTRIBUTING.md b/docs/dev/CONTRIBUTING.md index 6e009bb6..ccb77106 100644 --- a/docs/dev/CONTRIBUTING.md +++ b/docs/dev/CONTRIBUTING.md @@ -38,8 +38,8 @@ git remote add upstream https://github.com/ORIGINAL_AUTHOR/rustynes.git ### Build and Test ```bash -cargo build -cargo test +cargo build --workspace +cargo test --workspace ``` See [BUILD.md](BUILD.md) for detailed build instructions. diff --git a/docs/dev/DEBUGGING.md b/docs/dev/DEBUGGING.md index 99166297..ee95c46a 100644 --- a/docs/dev/DEBUGGING.md +++ b/docs/dev/DEBUGGING.md @@ -277,7 +277,7 @@ cargo test --test blargg_cpu_exec_space **Using perf**: ```bash -cargo build --release +cargo build --release -p rustynes-frontend perf record -g ./target/release/rustynes rom.nes perf report ``` diff --git a/docs/libretro/advanced_features.md b/docs/libretro/advanced_features.md index 83246552..979efe70 100644 --- a/docs/libretro/advanced_features.md +++ b/docs/libretro/advanced_features.md @@ -2,16 +2,16 @@ 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 +## Direct Memory Mapping & RetroAchievements (implemented) 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. +`RustyNesLibretro::register_memory_maps` (`crates/rustynes-libretro/src/lib.rs`) bypasses this via `RETRO_ENVIRONMENT_SET_MEMORY_MAPS`, called at the end of `on_load_game` on the `LoadGameContext` (this hook is only available there and on `InitContext` — **not** on `SetEnvironmentContext`, since the memory pointers aren't known until a ROM is loaded). It 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`. +* **Save RAM (SRAM):** Maps address range `$6000 - $7FFF`, when non-empty (battery-backed carts only). 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. +The legacy `get_memory_data`/`get_memory_size` (`RETRO_MEMORY_*`) pointer path is kept alongside this, unchanged — RetroArch's own `.srm` persistence goes through it regardless, so the descriptor registration is additive, not a replacement. Both paths expose the MAIN console's memory in Vs. `DualSystem` mode (see `RustyNesLibretro::active_nes_mut`/`active_nes`). ## SRAM and Virtual File System (VFS) Offloading @@ -32,7 +32,7 @@ To support this, `rustynes-libretro` relies on the deterministic serialization e 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. +3. **Fast-Forward Optimization (`get_fastforwarding`, implemented):** `RustyNesLibretro::is_fastforwarding` queries this each frame in `run_single`/`run_dual` and skips the `push_audio` call (the `f32`→`i16` interleave plus the `batch_audio_samples` FFI push) while fast-forwarding. This is a modest, honest win, not a large one: `rustynes-core` has no mixer-bypass API, so the dominant cost — APU synthesis inside `run_frame()` — is not skipped; only the presentation-side audio conversion/push is. ## Vs. `DualSystem` Two-Screen Presentation (v2.1.10 "Web Parity") @@ -67,8 +67,14 @@ The deterministic `no_std` core is untouched — this is purely a parallel present/serialize branch in the FFI wrapper, exactly mirroring the desktop frontend's `emu.dual` branch. -## Famicom Disk System (FDS) Subsystem Negotiation +## Famicom Disk System (FDS) Loading & Disk Control (implemented) 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. + +**Load path.** `on_load_game` (`crates/rustynes-libretro/src/lib.rs`) inspects the extension libretro's `GET_GAME_INFO_EXT` reports (`ext_info.ext`, valid even in in-memory/`need_fullpath = false` mode). For `.fds` content, it looks up `disksys.rom` via `RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` (`GenericContext::get_system_directory`), reads it with `std::fs::read` (this crate links full `std`, unlike the `no_std` `rustynes-core`), and constructs via `rustynes_core::Nes::from_disk(disk_bytes, bios_bytes)`. Missing BIOS surfaces as a clear `on_load_game` error naming the expected path. **This is a simpler alternative to `RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO`/`retro_load_game_special`** (an earlier design considered but not built) — routing on the single game-load path avoids the added subsystem-registration/multi-buffer-negotiation surface for no loss of functionality, since RetroArch always resolves `disksys.rom` from the system directory the same way regardless. + +**Multi-side disk swap.** Once loaded, the disk-control trait overrides (`on_set_eject_state`, `on_get_eject_state`, `on_get_image_index`, `on_set_image_index`, `on_get_num_images`, `on_get_image_path`/`on_get_image_label`) are backed by `Nes::disk_side_count`/`inserted_disk_side`/`set_disk_side` — the same API the desktop frontend's F9 disk-swap keybind uses. The callback trampolines are registered once via `GenericContext::enable_disk_control_interface()` in `on_set_environment`, surfacing swap/eject in RetroArch's Quick Menu → Disk Control. `on_get_image_path`/`on_get_image_label` synthesize "Side A"/"Side B" labels since no real per-side file paths exist for a single multi-side `.fds` container; `on_replace_image_index`/`on_add_image_index` are left at their default no-ops for the same reason. + +## Native Cheats (Game Genie, implemented) + +`on_cheat_set`/`on_cheat_reset` are backed by `Nes::add_genie_code`/`remove_genie_code`/`clear_genie_codes`, which are deliberately excluded from serialized state — cheats never affect save-state / netplay / TAS determinism. `RustyNesLibretro::genie_cheats` (an `index -> code` map) remembers which code was applied at each frontend-assigned cheat slot, since `on_cheat_set` only reports the code being toggled, not what was previously there. Only Game Genie code syntax is decoded — a generic RetroArch "raw address:value" poke cheat is not, so `RetroArch Cheats` (the frontend's own address-poke cheat manager) stays unsupported while `Native Cheats` (Game Genie) is supported. From 3811d2d8fdeaf1469df67c99c6435adefae38946 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 19 Jul 2026 17:43:55 -0400 Subject: [PATCH 2/3] docs(libretro): fix RetroArch-Cheats vs Native-Cheats mischaracterization The prior commit's advanced_features.md update incorrectly implied RetroArch's own address-poke cheat manager ('RetroArch Cheats') was unsupported because on_cheat_set only decodes Game Genie syntax. Per docs/guides/cheat-codes.md upstream, that mechanism ('RetroArch Handled' cheats) never calls on_cheat_set at all -- RetroArch pokes the core's exposed memory directly via the same get_memory_data/ get_memory_size pointer API RetroAchievements uses, which has worked since that API was first implemented. Only 'Emulator Handled' (native) cheats go through on_cheat_set, which is what the Game Genie decoding actually adds. --- docs/libretro/advanced_features.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/libretro/advanced_features.md b/docs/libretro/advanced_features.md index 979efe70..942ceea3 100644 --- a/docs/libretro/advanced_features.md +++ b/docs/libretro/advanced_features.md @@ -75,6 +75,8 @@ Standard NES ROMs (`.nes`) bundle all data in a single file. The Famicom Disk Sy **Multi-side disk swap.** Once loaded, the disk-control trait overrides (`on_set_eject_state`, `on_get_eject_state`, `on_get_image_index`, `on_set_image_index`, `on_get_num_images`, `on_get_image_path`/`on_get_image_label`) are backed by `Nes::disk_side_count`/`inserted_disk_side`/`set_disk_side` — the same API the desktop frontend's F9 disk-swap keybind uses. The callback trampolines are registered once via `GenericContext::enable_disk_control_interface()` in `on_set_environment`, surfacing swap/eject in RetroArch's Quick Menu → Disk Control. `on_get_image_path`/`on_get_image_label` synthesize "Side A"/"Side B" labels since no real per-side file paths exist for a single multi-side `.fds` container; `on_replace_image_index`/`on_add_image_index` are left at their default no-ops for the same reason. -## Native Cheats (Game Genie, implemented) +## Cheats: RetroArch-handled (implemented since the legacy memory API) and Native (Game Genie, implemented) -`on_cheat_set`/`on_cheat_reset` are backed by `Nes::add_genie_code`/`remove_genie_code`/`clear_genie_codes`, which are deliberately excluded from serialized state — cheats never affect save-state / netplay / TAS determinism. `RustyNesLibretro::genie_cheats` (an `index -> code` map) remembers which code was applied at each frontend-assigned cheat slot, since `on_cheat_set` only reports the code being toggled, not what was previously there. Only Game Genie code syntax is decoded — a generic RetroArch "raw address:value" poke cheat is not, so `RetroArch Cheats` (the frontend's own address-poke cheat manager) stays unsupported while `Native Cheats` (Game Genie) is supported. +Per `docs/guides/cheat-codes.md` in `libretro/docs`, RetroArch has two independent cheat mechanisms: "RetroArch Handled" cheats, where RetroArch itself directly pokes the core's exposed memory (address/value/compare, via the Cheats UI or built-in memory search) through the *same* `get_memory_data`/`get_memory_size` pointer API used for RetroAchievements — this has worked since that API was first implemented, never touches `on_cheat_set` at all — and "Emulator Handled" (native) cheats, sent to the core via `on_cheat_set` for the core to decode in its own native format. + +`on_cheat_set`/`on_cheat_reset` are backed by `Nes::add_genie_code`/`remove_genie_code`/`clear_genie_codes`, which are deliberately excluded from serialized state — cheats never affect save-state / netplay / TAS determinism. `RustyNesLibretro::genie_cheats` (an `index -> code` map) remembers which code was applied at each frontend-assigned cheat slot, since `on_cheat_set` only reports the code being toggled, not what was previously there. Only Game Genie code syntax is decoded through this path — a generic RetroArch "raw address:value" poke cheat never reaches `on_cheat_set` in the first place, so it's unaffected either way. From 60bce9c6aa0a1950e9c8da6a30692f29671f62bb Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 19 Jul 2026 17:56:35 -0400 Subject: [PATCH 3/3] fix(libretro): address review findings on PR #312 - on_get_image_label: bound the synthesized 'Side X' letter to index < 26 instead of letting `b'A' + index as u8` overflow u8 (a debug panic, a silent wrap in release) for any frontend-supplied index past 'Z'; falls back to a numeric "Side N" label past that. - on_set_eject_state: return false up front when disk_side_count() == 0 (non-FDS content) instead of silently no-op-ing and reporting success. Verified this was never an actual panic risk -- the `Mapper` trait's default `set_disk_side` is an empty no-op, only fds.rs overrides it -- but reporting false is more honest. - on_cheat_set: remove whatever Game Genie code was previously applied at a slot before applying a new one there, instead of just overwriting the index->code map entry. Without this, re-toggling a cheat slot with a different code left the old patch active alongside the new one. - register_memory_maps: give the VRAM/nametable descriptor its own named "PPU" RETRO_ENVIRONMENT_SET_MEMORY_MAPS address space instead of the blank/default one. Nametable RAM (CIRAM) lives on the PPU's own internal bus -- on the CPU's real bus, $2000-$2007 are the PPU MMIO registers, not video RAM, so the default-space descriptor was misrepresenting it. Matches libretro.h's own documented convention for other separate buses (SNES SPC700 RAM's "S" space). - SUPPORT.md: fix two stale "Rust 1.86" references (current pin is 1.96, per rust-toolchain.toml) flagged near an edited line. Verified NOT a bug, so not changed: the reviewer-flagged concern that `register_memory_maps`'s local `Vec` could be a use-after-free once dropped. Checked against RetroArch's actual runloop.c handling of RETRO_ENVIRONMENT_SET_MEMORY_MAPS: it calloc's its own descriptor array and copies each entry by value (`sys_info->mmaps.descriptors[i].core = mmaps->descriptors[i]`) before returning, so the frontend never retains a pointer into our transient Vec. Only the `ptr` field *within* each descriptor (into rustynes-core's heap-owned WRAM/SRAM/VRAM buffers) needs to outlive the call, and it already does -- the same buffers the pre-existing get_memory_data path has relied on since before this PR. --- SUPPORT.md | 4 +-- crates/rustynes-libretro/src/lib.rs | 46 ++++++++++++++++++++++++++--- docs/libretro/advanced_features.md | 8 ++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/SUPPORT.md b/SUPPORT.md index ad91d8ca..2c26d93c 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -140,13 +140,13 @@ cargo build --release --workspace **Q: What are the prerequisites?** -A: Rust 1.86 (pinned in `rust-toolchain.toml`; `rustup` auto-installs it) and the `winit` + `wgpu` + `cpal` system libraries (libxkbcommon / wayland / alsa / udev on Linux; nothing extra on macOS/Windows). See [docs/dev/BUILD.md](docs/dev/BUILD.md) for platform-specific instructions. +A: Rust 1.96 (pinned in `rust-toolchain.toml`; `rustup` auto-installs it) and the `winit` + `wgpu` + `cpal` system libraries (libxkbcommon / wayland / alsa / udev on Linux; nothing extra on macOS/Windows). See [docs/dev/BUILD.md](docs/dev/BUILD.md) for platform-specific instructions. **Q: Build is failing, what do I do?** A: -1. Ensure you have Rust 1.86 or newer: `rustc --version` +1. Ensure you have Rust 1.96 or newer: `rustc --version` 2. Install the frontend system libraries (see [BUILD.md](docs/dev/BUILD.md)) 3. Try a clean build: `cargo clean && cargo build --workspace` 4. Check [GitHub Issues](https://github.com/doublegate/RustyNES/issues) for known build problems diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index fd841723..df661a19 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -75,6 +75,11 @@ const NES_H: usize = 240; /// Composed width of a Vs. `DualSystem` side-by-side present (two consoles). const DUAL_W: usize = NES_W * 2; +/// Named `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` address space for PPU nametable +/// RAM (CIRAM), distinct from the blank/default CPU-bus address space WRAM +/// and SRAM are registered under. See `register_memory_maps`. +const PPU_ADDRSPACE: &CStr = c"PPU"; + /// The central libretro core structure for RustyNES. /// /// This struct holds the underlying cycle-accurate `Nes` emulator instance alongside @@ -272,6 +277,13 @@ impl RustyNesLibretro { addrspace: std::ptr::null(), }); } + // Nametable RAM (CIRAM) lives on the PPU's own internal address bus, not + // the CPU's: on the CPU bus, $2000-$2007 are the PPU MMIO registers + // (PPUCTRL/PPUMASK/etc.), not video RAM. Registering this under the + // blank/default (CPU) address space at $2000 would therefore be + // misleading, so it gets its own named "PPU" address space instead — + // the same convention `libretro.h` documents for other genuinely + // separate buses (e.g. the SNES SPC700 audio coprocessor's "S" space). descriptors.push(retro_memory_descriptor { flags: u64::from(RETRO_MEMDESC_VIDEO_RAM), ptr: nes.vram_mut().as_mut_ptr().cast::(), @@ -280,7 +292,7 @@ impl RustyNesLibretro { select: 0, disconnect: 0, len: nes.vram_mut().len(), - addrspace: std::ptr::null(), + addrspace: PPU_ADDRSPACE.as_ptr(), }); let map = retro_memory_map { descriptors: descriptors.as_ptr(), @@ -724,6 +736,13 @@ impl Core for RustyNesLibretro { let Some(nes) = self.active_nes_mut() else { return false; }; + // Cartridge builds report 0 sides; `Nes::set_disk_side` is a safe no-op + // for them (the `Mapper` trait's default impl), so this can't panic + // either way, but reporting `false` for non-disk content is more + // honest than silently no-op-ing and claiming success. + if nes.disk_side_count() == 0 { + return false; + } if ejected { nes.set_disk_side(None); } else { @@ -768,9 +787,19 @@ impl Core for RustyNesLibretro { } fn on_get_image_label(&mut self, index: u32) -> Option { - // Synthesize "Side A" / "Side B" / ... labels for the Quick Menu. - let letter = char::from(b'A' + u8::try_from(index).ok()?); - CString::new(format!("Side {letter}")).ok() + // Synthesize "Side A" / "Side B" / ... labels for the Quick Menu. No FDS + // image realistically has more than a handful of sides, but `index` is + // frontend-supplied, so bound it explicitly rather than let `b'A' + index` + // overflow `u8` (a debug-build panic, a silent wrap in release) for any + // value past 'Z'. + if let Ok(index_u8) = u8::try_from(index) + && index_u8 < 26 + { + let letter = char::from(b'A' + index_u8); + CString::new(format!("Side {letter}")).ok() + } else { + CString::new(format!("Side {}", index.saturating_add(1))).ok() + } } // --- Cheats (native Game Genie) ------------------------------------------------ @@ -793,6 +822,15 @@ impl Core for RustyNesLibretro { return; }; if enabled { + // A frontend may re-toggle/edit the code at an already-active slot + // without disabling it first; remove whatever code was previously + // applied at this index so it doesn't stay active alongside the new + // one (both would otherwise patch the ROM simultaneously). + if let Some(old_code) = self.genie_cheats.remove(&index) + && let Some(nes) = self.active_nes_mut() + { + nes.remove_genie_code(&old_code); + } let applied = self .active_nes_mut() .is_some_and(|nes| nes.add_genie_code(code_str).is_ok()); diff --git a/docs/libretro/advanced_features.md b/docs/libretro/advanced_features.md index 942ceea3..1da96299 100644 --- a/docs/libretro/advanced_features.md +++ b/docs/libretro/advanced_features.md @@ -5,11 +5,11 @@ To fulfill RustyNES's preservation-grade feature set (GGPO rollback, RetroAchiev ## Direct Memory Mapping & RetroAchievements (implemented) 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. -`RustyNesLibretro::register_memory_maps` (`crates/rustynes-libretro/src/lib.rs`) bypasses this via `RETRO_ENVIRONMENT_SET_MEMORY_MAPS`, called at the end of `on_load_game` on the `LoadGameContext` (this hook is only available there and on `InitContext` — **not** on `SetEnvironmentContext`, since the memory pointers aren't known until a ROM is loaded). It exposes an array of `retro_memory_descriptor` structures directly mapping the emulator's 6502 CPU virtual address space to physical Rust heap pointers: +`RustyNesLibretro::register_memory_maps` (`crates/rustynes-libretro/src/lib.rs`) bypasses this via `RETRO_ENVIRONMENT_SET_MEMORY_MAPS`, called at the end of `on_load_game` on the `LoadGameContext` (this hook is only available there and on `InitContext` — **not** on `SetEnvironmentContext`, since the memory pointers aren't known until a ROM is loaded). It exposes an array of `retro_memory_descriptor` structures: -* **Work RAM (WRAM):** Maps address range `$0000 - $07FF`. Flagged as `RETRO_MEMDESC_SYSTEM_RAM`. -* **Save RAM (SRAM):** Maps address range `$6000 - $7FFF`, when non-empty (battery-backed carts only). Flagged as `RETRO_MEMDESC_SAVE_RAM`. -* **Video RAM (VRAM):** Maps address range `$2000 - $2FFF`. Flagged as `RETRO_MEMDESC_VIDEO_RAM`. +* **Work RAM (WRAM):** Maps address range `$0000 - $07FF` in the blank/default (6502 CPU) address space. Flagged as `RETRO_MEMDESC_SYSTEM_RAM`. +* **Save RAM (SRAM):** Maps address range `$6000 - $7FFF` in the same CPU address space, when non-empty (battery-backed carts only). Flagged as `RETRO_MEMDESC_SAVE_RAM`. +* **Video RAM (VRAM):** Maps address range `$2000 - $2FFF`, flagged as `RETRO_MEMDESC_VIDEO_RAM` — but in a **named `"PPU"` address space**, not the blank/default one, since nametable RAM (CIRAM) lives on the PPU's own internal bus. On the CPU's real bus, `$2000-$2007` are the PPU MMIO registers (PPUCTRL/PPUMASK/etc.), not video RAM; registering the VRAM pointer under the default CPU space at that range would misrepresent it. This mirrors the convention `libretro.h` documents for other genuinely separate buses (e.g. the SNES SPC700 audio coprocessor's `"S"` address space). The legacy `get_memory_data`/`get_memory_size` (`RETRO_MEMORY_*`) pointer path is kept alongside this, unchanged — RetroArch's own `.srm` persistence goes through it regardless, so the descriptor registration is additive, not a replacement. Both paths expose the MAIN console's memory in Vs. `DualSystem` mode (see `RustyNesLibretro::active_nes_mut`/`active_nes`).