From a7eb5ff7f9db7d6f91049edaa73f25ca6ca73fef Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 11 Jul 2026 04:47:24 -0400 Subject: [PATCH 1/3] feat(core,ppu,cart): add raw WRAM/VRAM/SRAM slice accessors Adds Bus::wram/wram_mut, Ppu::vram/vram_mut, and Cart::sram_mut -- flat byte/word-slice views over storage that previously only had word-at-a-time debug accessors (peek_wram/vram_word) or a copy-based restore (load_sram). Purely additive, zero behavior change. Needed by the upcoming rustysnes-libretro crate's get_memory_data/get_memory_size (RETRO_MEMORY_ SYSTEM_RAM/VIDEO_RAM/SAVE_RAM), which hands RetroAchievements/cheat tooling a raw pointer into live emulator memory rather than a copy. Co-Authored-By: Claude Sonnet 5 --- crates/rustysnes-cart/src/lib.rs | 10 ++++++++++ crates/rustysnes-core/src/bus.rs | 25 +++++++++++++++++++++++++ crates/rustysnes-ppu/src/lib.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/crates/rustysnes-cart/src/lib.rs b/crates/rustysnes-cart/src/lib.rs index ebfd3657..ebb44dda 100644 --- a/crates/rustysnes-cart/src/lib.rs +++ b/crates/rustysnes-cart/src/lib.rs @@ -103,6 +103,13 @@ impl Cart { dst[..n].copy_from_slice(&data[..n]); } + /// The mutable counterpart to [`Self::save_sram`] — for a host embedder that needs a raw + /// memory-map pointer (e.g. a libretro core's `RETRO_MEMORY_SAVE_RAM`, which some frontends + /// write through directly rather than only calling [`Self::load_sram`]). + pub fn sram_mut(&mut self) -> &mut [u8] { + self.board.sram_mut() + } + /// Advance any on-cart coprocessor by one of its clock units. Default boards no-op. pub fn coprocessor_tick(&mut self) { self.board.coprocessor_tick(); @@ -186,5 +193,8 @@ mod tests { snap[0x10] = 0xEE; cart.load_sram(&snap); assert_eq!(cart.read24(0x70_0010, 0x00), 0xEE); + // sram_mut() is the same backing storage save_sram()/load_sram() operate on. + cart.sram_mut()[0x20] = 0x11; + assert_eq!(cart.read24(0x70_0020, 0x00), 0x11); } } diff --git a/crates/rustysnes-core/src/bus.rs b/crates/rustysnes-core/src/bus.rs index 3db1f1be..140c70af 100644 --- a/crates/rustysnes-core/src/bus.rs +++ b/crates/rustysnes-core/src/bus.rs @@ -422,6 +422,21 @@ impl Bus { } } + /// The full 128 KiB WRAM as a flat byte slice (linear address `0..0x1_FFFF`, the same mapping + /// [`Self::peek_wram`]'s `0x7E..=0x7F` bank arm uses) — for a host embedder that needs a raw + /// memory-map pointer (e.g. a libretro core's `RETRO_MEMORY_SYSTEM_RAM`). + #[must_use] + pub fn wram(&self) -> &[u8] { + &*self.wram + } + + /// The mutable counterpart to [`Self::wram`] — same host-embedder use case (a libretro + /// frontend's memory-map API hands this pointer to RetroAchievements/cheat tooling that + /// writes through it directly). + pub fn wram_mut(&mut self) -> &mut [u8] { + &mut *self.wram + } + /// Non-intrusive read of an arbitrary 24-bit CPU address, for the debugger overlay's /// disassembly view (`v0.9.0`, T-81-001 PR B). Unlike [`CpuBus::read24`], this does NOT touch /// the open-bus latch, does NOT check watchpoints, and does NOT trigger any I/O register's own @@ -1187,6 +1202,16 @@ mod tests { assert_eq!(::read24(&mut bus, 0x7E_0042), 0x99); } + #[test] + fn wram_and_wram_mut_expose_the_same_flat_128kib() { + let mut bus = Bus::default(); + assert_eq!(bus.wram().len(), 0x2_0000); + ::write24(&mut bus, 0x7E_1234, 0xAB); + assert_eq!(bus.wram()[0x1234], 0xAB); + bus.wram_mut()[0x5678] = 0xCD; + assert_eq!(::read24(&mut bus, 0x7E_5678), 0xCD); + } + #[test] fn peek_reads_wram_without_side_effects() { let mut bus = Bus::default(); diff --git a/crates/rustysnes-ppu/src/lib.rs b/crates/rustysnes-ppu/src/lib.rs index 67844020..661df34e 100644 --- a/crates/rustysnes-ppu/src/lib.rs +++ b/crates/rustysnes-ppu/src/lib.rs @@ -972,6 +972,19 @@ impl Ppu { self.vram[(addr & 0x7fff) as usize] } + /// The full 64 KiB VRAM as a flat word slice (32Ki x `u16`, native word addressing) — for a + /// host embedder that needs a raw memory-map pointer (e.g. a libretro core's + /// `RETRO_MEMORY_VIDEO_RAM`). + #[must_use] + pub fn vram(&self) -> &[u16] { + &*self.vram + } + + /// The mutable counterpart to [`Self::vram`] — same host-embedder use case. + pub fn vram_mut(&mut self) -> &mut [u16] { + &mut *self.vram + } + /// Read a CGRAM entry directly (test/diagnostic). #[must_use] pub const fn cgram_word(&self, index: u8) -> u16 { @@ -1252,4 +1265,18 @@ mod tests { let q = p.clone(); assert_eq!(q.vram_word(0), 0x1234); } + + #[test] + fn vram_and_vram_mut_expose_the_same_flat_64kib() { + let mut p = Ppu::new(); + assert_eq!(p.vram().len(), 0x8000); + p.write_reg(0x2115, 0x80); + p.write_reg(0x2116, 0x00); + p.write_reg(0x2117, 0x00); + p.write_reg(0x2118, 0x34); + p.write_reg(0x2119, 0x12); + assert_eq!(p.vram()[0], 0x1234); + p.vram_mut()[1] = 0xABCD; + assert_eq!(p.vram_word(1), 0xABCD); + } } From 7797044eec4ce875d84c2e296c3a6c5c55ff3bca Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 11 Jul 2026 05:14:04 -0400 Subject: [PATCH 2/3] feat(libretro): add rustysnes-libretro core crate (v1.2.0) A thin C-ABI wrapper over rustysnes_core::facade::EmuCore, loadable by RetroArch or any other libretro-compatible frontend. Duplicates zero emulation logic -- every hook is a translation between the libretro C ABI and EmuCore's existing safe Rust API. - Region-aware NTSC/PAL geometry+timing, corrected via RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO on the first on_run after a ROM loads (the true region is only known once the cart header is parsed). - The S-DSP's real 32 kHz sample rate (matching rustysnes-frontend's own audio_core::SDSP_RATE convention), not a placeholder 44100. - Coprocessor firmware (DSP-1..4/CX4) auto-resolved from the frontend's system directory, mirroring the native frontend's own policy. - Game Genie/Pro Action Replay cheat support (on_cheat_set/ on_cheat_reset -> rustysnes_core::cheat::decode -> Bus::set_cheats). - Raw WRAM/VRAM/SRAM memory-map pointers (get_memory_data/ get_memory_size) for RetroArch's own SRAM autosave and RetroAchievements/cheat tooling -- new additive Bus::wram/wram_mut, Ppu::vram/vram_mut, Cart::sram_mut accessors support this. - retro_game_info is opaque in rust-libretro-sys 0.3.2's bindgen output (verified against the generated bindings); worked around via RETRO_ENVIRONMENT_GET_GAME_INFO_EXT + a hand-rolled repr(C) struct, the same proven pattern the sibling rustynes-libretro core uses. Peripheral negotiation (Mouse/Super Scope/Multitap via RETRO_DEVICE_SUBCLASS) is a documented follow-up, not yet wired -- see docs/libretro.md. Verified: cargo build produces a valid ELF cdylib + staticlib with every required libretro C-ABI symbol exported (checked via nm); full workspace suite, the ROM-oracle battery (28 tests/17 suites, zero regressions), clippy clean across default/flags-off/full, fmt clean, doc build clean, no_std clean, both wasm32 frontends build clean. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 7 + CHANGELOG.md | 16 +- Cargo.lock | 154 +++++++- Cargo.toml | 1 + README.md | 15 +- crates/rustysnes-libretro/Cargo.toml | 23 ++ crates/rustysnes-libretro/src/lib.rs | 507 +++++++++++++++++++++++++++ docs/STATUS.md | 18 +- docs/architecture.md | 8 +- docs/libretro.md | 113 ++++++ to-dos/ROADMAP.md | 6 +- to-dos/VERSION-PLAN.md | 27 ++ 12 files changed, 878 insertions(+), 17 deletions(-) create mode 100644 crates/rustysnes-libretro/Cargo.toml create mode 100644 crates/rustysnes-libretro/src/lib.rs create mode 100644 docs/libretro.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4afa4ba..4bcbaa75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,13 @@ jobs: # `hd-pack` placeholder) — testing it directly instead of re-listing the flags keeps this # step and `full`'s own Cargo.toml definition from silently drifting apart. - run: cargo clippy -p rustysnes-frontend --all-targets --features full -- -D warnings + # `rustysnes-libretro` (`v1.2.0`): already covered by the `--workspace` clippy line above (a + # regular workspace member, no non-default features to combo over), but clippy doesn't + # necessarily exercise the FFI-crate-specific bit that matters most for a `cdylib`/ + # `staticlib`: does it actually LINK. A build-only step (not `cargo test` -- this crate has + # no tests of its own; its logic lives in and is tested by `rustysnes-core`) is cheap and + # catches a broken libretro C-ABI export/link before it reaches a release artifact. + - run: cargo build -p rustysnes-libretro # Cheap locally (~4s) so it belongs on every PR, not just tag pushes -- catches broken # intra-doc links and rustdoc-specific warnings clippy's own lints don't cover. - run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2853ce..dc4dd475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (breakpoints, single-step, VRAM viewer scroll) on top of the relocated facade. Zero behavior change: every pure-facade method is a one-line delegation, verified by the unchanged frontend test suite, the full ROM-oracle battery, and the `no_std` CI job (the acid test that the new - `#[cfg(feature = "std")]` gate actually removes the facade from the `thumbv7em` build). See + `#[cfg(feature = "std")]` gate actually removes the facade from the `thumbv7em` build). Also + fixes a determinism-seed-discarding bug found in review: `load_rom`/`power_cycle`/`close_rom` + rebuilt `System::new(0)` on every call, silently ignoring the caller's seed. See `docs/architecture.md` §3/§6 and `docs/frontend.md`. +### Added + +- **`rustysnes-libretro`: a libretro core.** A thin C-ABI wrapper over + `rustysnes_core::facade::EmuCore`, loadable by RetroArch or any other libretro-compatible + frontend — region-aware NTSC/PAL geometry+timing, the S-DSP's real 32 kHz output rate, + coprocessor firmware auto-resolution from the frontend's system directory, Game Genie/Pro + Action Replay cheat support, and raw WRAM/VRAM/SRAM memory-map pointers for RetroArch's own + SRAM autosave and RetroAchievements/cheat tooling. Peripheral negotiation (Mouse/Super + Scope/Multitap via `RETRO_DEVICE_SUBCLASS`) is a documented follow-up, not yet wired. New + additive `Bus::wram`/`wram_mut`, `Ppu::vram`/`vram_mut`, `Cart::sram_mut` accessors support it. + See `docs/libretro.md`. + ## [1.1.0] "Latchkey" - 2026-07-11 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 98409d28..4240ea19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,28 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[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" @@ -409,7 +431,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]] @@ -451,6 +482,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" @@ -580,6 +622,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" @@ -1400,6 +1448,12 @@ dependencies = [ "vello_common", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.17.0" @@ -1543,6 +1597,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" @@ -1893,6 +1956,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 = "libc" version = "0.2.186" @@ -2016,7 +2085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" dependencies = [ "cc", - "which", + "which 8.0.4", ] [[package]] @@ -2732,6 +2801,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" @@ -3315,6 +3390,54 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -3489,6 +3612,15 @@ dependencies = [ "zip", ] +[[package]] +name = "rustysnes-libretro" +version = "1.1.0" +dependencies = [ + "libc", + "rust-libretro", + "rustysnes-core", +] + [[package]] name = "rustysnes-netplay" version = "1.1.0" @@ -3653,6 +3785,12 @@ dependencies = [ "digest", ] +[[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" @@ -4830,6 +4968,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 fec63ad3..dd887608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/rustysnes-cheevos", "crates/rustysnes-script", "crates/rustysnes-frontend", + "crates/rustysnes-libretro", "crates/rustysnes-test-harness", ] diff --git a/README.md b/README.md index 081003ae..641275d0 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ that). The reproducible record (methodology, all benches, and save-state cost) i | [Project status matrix](docs/STATUS.md) | Per-suite pass count, coprocessor coverage, feature flags, version policy — the single source of truth | | [Architecture](docs/architecture.md) | System design and the load-bearing decisions | | [Frontend](docs/frontend.md) | The desktop/wasm shell, save states, pacing, the debugger overlay, scripting, netplay, RetroAchievements | +| [Libretro core](docs/libretro.md) | `rustysnes-libretro`, a RetroArch-loadable core — build steps, manual verification, known scope cuts | | [CHANGELOG.md](CHANGELOG.md) | Version history and release notes | | [Roadmap](to-dos/ROADMAP.md) | The forward roadmap — the phase spine | | [Version plan](to-dos/VERSION-PLAN.md) | The named, versioned release ladder to `v1.0.0` and beyond | @@ -359,9 +360,9 @@ API docs (rustdoc) at ## Current Release -RustySNES's current release is **v1.0.1 "Aftertouch"**. See +RustySNES's current release is **v1.1.0 "Latchkey"**. See [`docs/STATUS.md`](docs/STATUS.md) for the full release history -(`v0.1.0` through `v1.0.1`) and per-release detail. +(`v0.1.0` through `v1.1.0`) and per-release detail. - **Download:** the [GitHub Releases](https://github.com/doublegate/RustySNES/releases) page — desktop binaries for Linux, macOS (aarch64), and Windows. @@ -374,16 +375,20 @@ RustySNES's current release is **v1.0.1 "Aftertouch"**. See explicitly deferred out of that cut: per-channel (per-voice) audio mutes and global keyboard hotkeys — both landed (see the Desktop UX + Audio sections above, `CHANGELOG.md`). -**`v1.1.0`** (in progress) is a research + accuracy pass: a real, independent bug fix +**`v1.1.0`** was a research + accuracy pass: a real, independent bug fix (`SuperFxBoard::map`'s Game-Pak-RAM-ownership open-bus gap), `emu-thread`'s two biggest gaps closed (real audio output + a proper pause/ROM-loaded/speed lifecycle — still not full parity with the synchronous drive), and three accuracy investigations (open-bus-via-DMA-latch, DRAM refresh timing, and a fractional-timebase-refactor go/no-go assessment) — see `CHANGELOG.md` and `to-dos/VERSION-PLAN.md`'s `v1.1.0` section for the full breakdown, including what's still open. +**`v1.2.0`** (in progress) relocates the pure `EmuCore` embedding facade into +`rustysnes-core::facade` and lands a real **Libretro core** (`rustysnes-libretro`, loadable by +RetroArch — region-aware NTSC/PAL, cheats, coprocessor firmware auto-resolution, raw memory-map +pointers; see `docs/libretro.md`); a **CRT/HQ2x shader pipeline** is next. + **Still deferred:** HD texture packs (the `hd-pack` flag exists in the manifest as a forward -placeholder; the loader itself is a TODO stub), a Libretro core, and a CRT/HQ2x shader pipeline — -all planned for the `v1.2.0`/`v1.3.0` follow-up arc. +placeholder; the loader itself is a TODO stub) — planned for `v1.3.0`. The full roadmap lives in [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) (the phase spine) and [`to-dos/VERSION-PLAN.md`](to-dos/VERSION-PLAN.md) (the named release ladder). diff --git a/crates/rustysnes-libretro/Cargo.toml b/crates/rustysnes-libretro/Cargo.toml new file mode 100644 index 00000000..04f4eca0 --- /dev/null +++ b/crates/rustysnes-libretro/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rustysnes-libretro" +description = "Cycle-accurate SNES emulator libretro core wrapper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "staticlib"] + +[lints] +workspace = true + +[dependencies] +libc = "0.2" +rust-libretro = "0.3.2" + +[dependencies.rustysnes-core] +path = "../rustysnes-core" +default-features = true diff --git a/crates/rustysnes-libretro/src/lib.rs b/crates/rustysnes-libretro/src/lib.rs new file mode 100644 index 00000000..e84534b2 --- /dev/null +++ b/crates/rustysnes-libretro/src/lib.rs @@ -0,0 +1,507 @@ +//! RustySNES Libretro Core. +//! +//! Implements the C ABI boundary for the `rustysnes-core` engine, exposing the standard libretro +//! lifecycle hooks (`retro_init`, `retro_load_game`, `retro_run`, etc.) required by RetroArch and +//! other compatible frontends. A thin, safe facade over +//! [`rustysnes_core::facade::EmuCore`] — the same relocated facade (`v1.2.0`) the native/wasm +//! frontend's own `EmuCore` wraps, so this core never duplicates emulation logic. +//! +//! # Architecture +//! +//! - **Video**: `EmuCore::framebuffer()` (RGBA8) is byte-swapped (R<->B) into XRGB8888 and handed +//! to `RunContext::draw_frame`. Geometry/timing are region-dependent (NTSC 256x224 @ 60.0988 Hz +//! vs. PAL 256x239 @ 50.007 Hz, `docs/scheduler.md`); the true region is only known once the +//! cart header has been parsed (`System::reset`, triggered by the first `run_frame`), so the +//! core reports a conservative NTSC default in `on_get_av_info` and corrects it via +//! `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO` on the first `on_run` after a ROM loads. +//! - **Audio**: `EmuCore::audio()` already produces 32 kHz signed 16-bit stereo pairs — no format +//! conversion needed, just interleaving into libretro's batch API. +//! - **Input**: only the standard 12-button pad is wired for P1/P2 (`RETRO_DEVICE_JOYPAD`). +//! Mouse / Super Scope / Multitap (already supported core-side via +//! `EmuCore::set_mouse`/`set_superscope`/`set_multitap_pad`) are NOT yet exposed through +//! `RETRO_DEVICE_SUBCLASS` device negotiation — a documented follow-up, not a silent gap (see +//! `docs/libretro.md`). +//! - **Firmware**: coprocessor firmware dumps (DSP-1..4, CX4) are auto-resolved from the +//! frontend's system directory (`RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY`), mirroring +//! `rustysnes-frontend`'s own `firmware_candidates`/`install_firmware` policy. +//! - **Save RAM / WRAM / VRAM**: exposed as raw pointers via `get_memory_data`/`get_memory_size` +//! (`Bus::wram_mut`/`Ppu::vram_mut`/`Cart::sram_mut`, `v1.2.0`) for RetroArch's own SRAM +//! autosave and for RetroAchievements/cheat tooling that reads memory directly. +//! - **Cheats**: Game Genie / Pro Action Replay codes via `on_cheat_set`/`on_cheat_reset`, decoded +//! through `rustysnes_core::cheat::decode` and applied via `Bus::set_cheats` — the same decoder +//! and CPU-read-intercept mechanism `rustysnes-frontend`'s Cheats window uses. +//! - **Save states**: `EmuCore::save_state`/`load_state` (a versioned `System` snapshot, +//! `docs/adr/0006`) via `get_serialize_size`/`on_serialize`/`on_unserialize`. + +// FFI boundary wrapper, same posture as rustysnes-frontend/rustysnes-cheevos: every unsafe use +// here is a `rust_libretro`-mandated raw pointer/environment-callback interaction, not emulation +// logic (which stays entirely safe, inside `rustysnes-core`). +#![allow(unsafe_code)] +#![allow(clippy::wildcard_imports)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_sign_loss)] +#![allow(clippy::doc_markdown)] + +use std::collections::BTreeMap; +use std::ffi::CString; + +use rust_libretro::{ + contexts::*, + core::{Core, CoreOptions}, + retro_core, + sys::*, + types::*, +}; +use rustysnes_core::cart::Region; +use rustysnes_core::cheat::CheatPatch; +use rustysnes_core::facade::{EmuCore, MAX_H, MAX_W, SNES_H_NTSC, SNES_H_PAL, SNES_W}; + +/// The S-DSP's fixed output rate (`docs/apu.md`) — matches `rustysnes-frontend`'s own +/// `audio_core::SDSP_RATE` convention (a round 32 kHz, not the more precise 32040 Hz some +/// documentation cites; this project's own established constant). +const SAMPLE_RATE: f64 = 32_000.0; +/// NTSC frame rate (60.0988 Hz) — matches `rustysnes-frontend::FRAME_RATE_NTSC`. +const FPS_NTSC: f64 = 60.098_8; +/// PAL frame rate (50.007 Hz) — matches `rustysnes-frontend::FRAME_RATE_PAL`. +const FPS_PAL: f64 = 50.006_98; + +/// The central libretro core structure for RustySNES. +pub struct RustySnesLibretro { + /// The relocated pure facade. `None` until `on_load_game` (power-on with no ROM has no + /// meaningful libretro state to expose — `retro_init` fires before any ROM is known). + core: Option, + /// Intermediate buffer for interleaved i16 stereo audio samples — pre-allocated so the hot + /// `on_run` loop avoids heap allocations in the steady state. + audio_buffer: Vec, + /// Intermediate buffer for the XRGB8888 video frame (byte-swapped from `EmuCore`'s RGBA8) — + /// pre-allocated to the hi-res worst case (`MAX_W` x `MAX_H` x 4 bytes). + video_buffer: Vec, + /// Pre-computed save-state size, evaluated once at `on_load_game` (a `System` snapshot's size + /// is constant for a given cart/board, `docs/adr/0006`). + serialize_size: usize, + /// Pre-allocated buffer for snapshot serialization. + serialize_buffer: Vec, + /// Set on `on_load_game`, cleared once the first `on_run` successfully reports the ROM's + /// auto-detected region via `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO` (only callable from + /// `RunContext`, hence deferred past `on_load_game` itself). + pending_av_info: bool, + /// Currently-armed cheats, keyed by libretro's `index` (`on_cheat_set`'s "replace the entry at + /// this index" contract) — `None` when the frontend cleared/disabled that index. Rebuilt into + /// a flat `Vec` and pushed to `Bus::set_cheats` on every change, mirroring + /// `rustysnes-frontend::cheats::sync`'s "always replace the whole active set" convention. + cheats: BTreeMap, +} + +impl Default for RustySnesLibretro { + fn default() -> Self { + Self { + core: None, + audio_buffer: Vec::with_capacity(4096), + video_buffer: Vec::with_capacity((MAX_W * MAX_H * 4) as usize), + serialize_size: 0, + serialize_buffer: Vec::new(), + pending_av_info: false, + cheats: BTreeMap::new(), + } + } +} + +impl CoreOptions for RustySnesLibretro {} + +/// A hand-rolled mirror of libretro's `retro_game_info_ext` C struct, bypassing +/// `rust-libretro-sys` 0.3.2's opaque bindgen output for it (see `on_load_game`'s doc). Field +/// order/types match `libretro.h` exactly (verified against the vendored header in +/// `rust-libretro-sys`'s own crate source) — every field here is either a plain pointer or a +/// `bool`, so this has no hidden padding/alignment surprises on any platform libretro targets. +#[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, +} + +/// `(width, height, fps)` for the given region — the geometry/timing `on_get_av_info` and the +/// post-load `SET_SYSTEM_AV_INFO` correction both report. +const fn region_av(region: Region) -> (u32, u32, f64) { + match region { + Region::Ntsc => (SNES_W, SNES_H_NTSC, FPS_NTSC), + Region::Pal => (SNES_W, SNES_H_PAL, FPS_PAL), + } +} + +/// Map a libretro `JoypadState` bitmask into RustySNES's canonical SNES auto-joypad `u16` +/// (`B Y Select Start Up Down Left Right A X L R`, MSB-first at bit 15 — see +/// `rustysnes-frontend::input`'s doc for the full bit-order rationale). Libretro's own +/// `RETRO_DEVICE_ID_JOYPAD_*` numbering conveniently covers the same 12 buttons, just in a +/// different bit order, so this is a fixed remap table, not a lossy conversion. +fn joypad_state_to_snes_bits(state: JoypadState) -> u16 { + let mut bits = 0u16; + let map: [(JoypadState, u16); 12] = [ + (JoypadState::B, 1 << 15), + (JoypadState::Y, 1 << 14), + (JoypadState::SELECT, 1 << 13), + (JoypadState::START, 1 << 12), + (JoypadState::UP, 1 << 11), + (JoypadState::DOWN, 1 << 10), + (JoypadState::LEFT, 1 << 9), + (JoypadState::RIGHT, 1 << 8), + (JoypadState::A, 1 << 7), + (JoypadState::X, 1 << 6), + (JoypadState::L, 1 << 5), + (JoypadState::R, 1 << 4), + ]; + for (flag, bit) in map { + if state.contains(flag) { + bits |= bit; + } + } + bits +} + +impl Core for RustySnesLibretro { + fn get_info(&self) -> SystemInfo { + SystemInfo { + library_name: CString::new("RustySNES").unwrap(), + library_version: CString::new(env!("CARGO_PKG_VERSION")).unwrap(), + valid_extensions: CString::new("sfc|smc|swc|fig").unwrap(), + need_fullpath: false, + block_extract: false, + } + } + + fn on_get_av_info(&mut self, _ctx: &mut GetAvInfoContext) -> retro_system_av_info { + // Conservative NTSC default before any ROM is loaded; corrected in `on_run` once the + // cart header's region byte has actually been parsed (see `region_av`/`pending_av_info`). + retro_system_av_info { + geometry: retro_game_geometry { + base_width: SNES_W, + base_height: SNES_H_NTSC, + max_width: MAX_W, + max_height: MAX_H, + aspect_ratio: 4.0 / 3.0, + }, + timing: retro_system_timing { + fps: FPS_NTSC, + sample_rate: SAMPLE_RATE, + }, + } + } + + fn on_set_environment(&mut self, _initial: bool, ctx: &mut SetEnvironmentContext) { + // SAFETY: the libretro API guarantees a valid environment callback pointer here; + // `set_pixel_format`/`set_input_descriptors` are safe FFI wrappers over it. + unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + let cb = *generic_ctx.environment_callback(); + + if !rust_libretro::environment::set_pixel_format(cb, PixelFormat::XRGB8888) { + eprintln!( + "[RustySNES] Error: frontend rejected XRGB8888 pixel format; colors will be broken." + ); + } + + let descriptors = rust_libretro::input_descriptors!( + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B, "B" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_Y, "Y" }, + { 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" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A, "A" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X, "X" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_L, "L" }, + { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_R, "R" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B, "B" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_Y, "Y" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_SELECT, "Select" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_START, "Start" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_UP, "D-Pad Up" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_DOWN, "D-Pad Down" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_LEFT, "D-Pad Left" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_RIGHT, "D-Pad Right" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A, "A" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X, "X" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_L, "L" }, + { 1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_R, "R" } + ); + rust_libretro::environment::set_input_descriptors(cb, &descriptors); + } + } + + fn on_load_game( + &mut self, + _game: Option, + ctx: &mut LoadGameContext, + ) -> Result<(), Box> { + // `rust-libretro-sys` 0.3.2's bindgen output makes `retro_game_info` an opaque 1-byte + // placeholder (a forward-declaration artifact — verified against the generated + // `bindings_libretro.rs`), so `_game` above is unusable. `RETRO_ENVIRONMENT_GET_GAME_INFO_EXT` + // is the proven workaround (same one `rustynes-libretro`, the sibling NES core, uses): + // a hand-rolled `#[repr(C)]` struct matching libretro's real `retro_game_info_ext` layout, + // fetched directly through 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). + if cb( + 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")?; + + if ext_info.data.is_null() { + return Err( + "ext_info data pointer is NULL (the frontend did not load the ROM into memory)" + .into(), + ); + } + // 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 rom_bytes = + unsafe { std::slice::from_raw_parts(ext_info.data.cast::(), ext_info.size) } + .to_vec(); + + let mut core = EmuCore::new(0, Region::Ntsc); + core.load_rom(&rom_bytes) + .map_err(|e| format!("failed to load ROM: {e}"))?; + + // Auto-resolve coprocessor firmware (DSP-1..4/CX4) from the frontend's system directory — + // mirrors `rustysnes-frontend`'s own `firmware_candidates`/`install_firmware` policy. + if core.needs_firmware() { + // SAFETY: same environment-callback contract as `on_set_environment`. + let system_dir = unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + let cb = *generic_ctx.environment_callback(); + rust_libretro::environment::get_system_directory(cb) + }; + if let Some(dir) = system_dir { + for name in core.firmware_candidates() { + if std::fs::read(dir.join(name)) + .is_ok_and(|bytes| core.install_firmware(&bytes)) + { + break; + } + } + } + if core.needs_firmware() { + eprintln!( + "[RustySNES] Warning: this cart needs coprocessor firmware ({:?}) not found \ + in the frontend's system directory; the coprocessor will be non-functional.", + core.firmware_candidates() + ); + } + } + + let mut tmp = Vec::new(); + tmp.extend_from_slice(&core.save_state()); + self.serialize_size = tmp.len(); + + self.core = Some(core); + self.pending_av_info = true; + self.cheats.clear(); + Ok(()) + } + + fn on_run(&mut self, ctx: &mut RunContext, _delta_us: Option) { + let Some(core) = self.core.as_mut() else { + return; + }; + + ctx.poll_input(); + for port in 0..2usize { + let jp = ctx.get_joypad_state(port as u32, 0); + core.set_pad(port, joypad_state_to_snes_bits(jp)); + } + + core.run_frame(); + + // `run_frame`'s first call (`System::run_frame` internally resets on `!booted`) is what + // actually parses the cart header and picks the real region — only correct after that + // point, hence deferred here rather than attempted in `on_load_game`. + if self.pending_av_info { + let region = core.system_mut().bus.ppu.region(); + let core_region = match region { + rustysnes_core::ppu::Region::Ntsc => Region::Ntsc, + rustysnes_core::ppu::Region::Pal => Region::Pal, + }; + let (w, h, fps) = region_av(core_region); + let av_info = retro_system_av_info { + geometry: retro_game_geometry { + base_width: w, + base_height: h, + max_width: MAX_W, + max_height: MAX_H, + aspect_ratio: 4.0 / 3.0, + }, + timing: retro_system_timing { + fps, + sample_rate: SAMPLE_RATE, + }, + }; + // SAFETY: `RunContext`'s own environment-callback contract; `av_info` is a plain, + // fully-initialized POD struct passed by value. + unsafe { + let generic_ctx: GenericContext = (&*ctx).into(); + let cb = *generic_ctx.environment_callback(); + rust_libretro::environment::set_system_av_info(cb, av_info); + } + self.pending_av_info = false; + } + + let (w, h) = core.fb_dims(); + self.video_buffer.clear(); + self.video_buffer.extend_from_slice(core.framebuffer()); + for chunk in self.video_buffer.chunks_exact_mut(4) { + chunk.swap(0, 2); // RGBA8 -> XRGB8888 (in-memory B,G,R,X): swap R and B. + } + ctx.draw_frame(&self.video_buffer, w, h, (w * 4) as usize); + + self.audio_buffer.clear(); + for &(l, r) in core.audio() { + self.audio_buffer.push(l); + self.audio_buffer.push(r); + } + AudioContext::from(&mut *ctx).batch_audio_samples(&self.audio_buffer); + } + + fn on_cheat_reset(&mut self, _ctx: &mut CheatResetContext) { + self.cheats.clear(); + if let Some(core) = self.core.as_mut() { + core.system_mut().bus.set_cheats(&[]); + } + } + + fn on_cheat_set( + &mut self, + index: std::os::raw::c_uint, + enabled: bool, + code: &std::ffi::CStr, + _ctx: &mut CheatSetContext, + ) { + if enabled { + if let Some(patch) = code + .to_str() + .ok() + .and_then(|s| rustysnes_core::cheat::decode(s).ok()) + { + self.cheats.insert(index, patch); + } else { + eprintln!("[RustySNES] Warning: could not decode cheat code at index {index}"); + self.cheats.remove(&index); + } + } else { + self.cheats.remove(&index); + } + if let Some(core) = self.core.as_mut() { + let patches: Vec = self.cheats.values().copied().collect(); + core.system_mut().bus.set_cheats(&patches); + } + } + + fn get_memory_data( + &mut self, + id: std::os::raw::c_uint, + _ctx: &mut GetMemoryDataContext, + ) -> *mut std::os::raw::c_void { + self.core + .as_mut() + .map_or(std::ptr::null_mut(), |core| match id { + RETRO_MEMORY_SAVE_RAM => { + core.system_mut() + .bus + .cart + .as_mut() + .map_or(std::ptr::null_mut(), |c| { + let sram = c.sram_mut(); + if sram.is_empty() { + std::ptr::null_mut() + } else { + sram.as_mut_ptr().cast::() + } + }) + } + RETRO_MEMORY_SYSTEM_RAM => core + .system_mut() + .bus + .wram_mut() + .as_mut_ptr() + .cast::(), + RETRO_MEMORY_VIDEO_RAM => core + .system_mut() + .bus + .ppu + .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.core.as_ref().map_or(0, |core| match id { + RETRO_MEMORY_SAVE_RAM => core.save_sram().len(), + RETRO_MEMORY_SYSTEM_RAM => 0x2_0000, // WRAM_SIZE (128 KiB), fixed regardless of cart. + RETRO_MEMORY_VIDEO_RAM => 0x8000 * 2, // 32Ki words * 2 bytes. + _ => 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 { + let Some(core) = self.core.as_ref() else { + return false; + }; + self.serialize_buffer.clear(); + self.serialize_buffer.extend_from_slice(&core.save_state()); + if slice.len() >= self.serialize_buffer.len() { + slice[..self.serialize_buffer.len()].copy_from_slice(&self.serialize_buffer); + true + } else { + false + } + } + + fn on_unserialize(&mut self, slice: &mut [u8], _ctx: &mut UnserializeContext) -> bool { + self.core + .as_mut() + .is_some_and(|core| core.load_state(slice).is_ok()) + } +} + +retro_core!(RustySnesLibretro { + core: None, + audio_buffer: Vec::with_capacity(4096), + video_buffer: Vec::with_capacity((MAX_W * MAX_H * 4) as usize), + serialize_size: 0, + serialize_buffer: Vec::new(), + pending_av_info: false, + cheats: BTreeMap::new(), +}); diff --git a/docs/STATUS.md b/docs/STATUS.md index 1854aa76..8e47aae9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,10 +3,11 @@ This file is authoritative for per-suite pass counts, the board / coprocessor matrix, and version policy. Everything else defers to it. -**Current release:** `v1.0.1 "Aftertouch"` (`v0.1.0 "Foundation"`, +**Current release:** `v1.1.0 "Latchkey"` (`v0.1.0 "Foundation"`, `v0.2.0 "Persistence"`, `v0.3.0 "Continuum"`, `v0.4.0 "Completion"`, `v0.5.0 "Fidelity"`, -`v0.6.0 "Shippable"`, `v0.7.0 "Resolution"`, `v0.8.0 "Community"`, `v0.9.0 "Threshold"`, and -`v1.0.0 "Zenith"` precede it; see `to-dos/VERSION-PLAN.md` for the full ladder). `v1.0.0` closes the production-cut +`v0.6.0 "Shippable"`, `v0.7.0 "Resolution"`, `v0.8.0 "Community"`, `v0.9.0 "Threshold"`, +`v1.0.0 "Zenith"`, and `v1.0.1 "Aftertouch"` precede it; see `to-dos/VERSION-PLAN.md` for the full +ladder). `v1.0.0` closes the production-cut gate: `Board: Send` (unblocking `emu-thread` to compile/test/lint clean for the first time, though it stays off-by-default pending full feature parity — see `docs/frontend.md`), the five desktop-UX-shell-maturity items (thumbnail Save States manager, key-rebind grid, themes, speed @@ -25,7 +26,7 @@ workspace suite (incl. `--features test-roms`, all 27 accuracy/oracle suites), ` full clippy matrix are all green with zero regressions. Shipped as `v1.0.1` per explicit project-owner instruction, deviating from this project's own "additive changes ship as MINOR" convention for this one release — see `CHANGELOG.md`. -**In progress: `v1.1.0`** — a research + accuracy pass following up on the Reach-phase backlog. +`v1.1.0` was a research + accuracy pass following up on the Reach-phase backlog. Landed: a real, independent bug fix (`SuperFxBoard::map`'s Game-Pak-RAM-ownership open-bus gap, zero regressions across the full `--features test-roms` battery) and `emu-thread`'s biggest gaps (real audio output via a thread-owned `AudioProducer`, plus a proper pause/ROM-loaded/speed @@ -36,6 +37,13 @@ via DMA/HDMA), DRAM refresh (empirically measured to already be correct without stall — implementing the originally-planned fix would have been a regression, `docs/scheduler.md` §DRAM refresh), and a fractional-timebase refactor go/no-go assessment (conclusion: not warranted yet, `docs/audit/fractional-timebase-go-no-go-2026-07-11.md`). +**In progress: `v1.2.0`** — Libretro core + CRT/HQ2x shader pipeline. Landed so far: the pure +`EmuCore` embedding facade relocated from `rustysnes-frontend` into a new `std`-only +`rustysnes_core::facade` module (zero behavior change, plus a determinism-seed-discarding bug +found in review — see `docs/architecture.md` §3/§6), and `rustysnes-libretro`, a real libretro +core wrapping that facade (region-aware NTSC/PAL geometry+timing, cheat support, coprocessor +firmware auto-resolution, raw WRAM/VRAM/SRAM memory-map pointers — see `docs/libretro.md`). The +CRT/HQ2x shader pipeline has not started yet. `v0.5.0` closed out the accuracy-pass-rate dashboard (see "Accuracy dashboard" below) and the full named hardware-gotcha regression list — every item fixed, correctly reclassified as an intentional non-goal, or honestly researched-and-deferred with a full mechanism write-up. `v0.6.0` @@ -130,7 +138,7 @@ added to this table; hi-res color-math precision itself closed in `v0.7.0 "Resol | `rustysnes-ppu` | PPU1 (5C77) + PPU2 (5C78) | **Phase 2 — BG 0-7 + Mode 7 + 128-sprite OAM + color math + windows + dot/HV timeline; per-scanline compositor renders each line at `RENDER_DOT` (dot 276, one dot before that line's own HDMA run can mutate the registers the composite reads) — a per-line HDMA-driven register write only becomes visible starting the following line, matching real hardware, landed `v0.8.0` (`docs/ppu.md` §Mid-scanline/HDMA-driven register timing; SA-1's `SD F-1 Grand Prix` + 24 Super FX/GSU goldens updated, each independently row-level-verified, not blindly re-blessed). True 512-px hi-res (Modes 5/6, pseudo-hires) output landed `v0.7.0` — a genuine one-pixel-clock-delayed dual-column DAC pass mirroring ares' `PPU::DAC`, unit-verified + non-regression-verified, real-title validation still open (`docs/ppu.md` §Hi-res (Modes 5/6) color-math precision). Color fixes (ares pixel-diff vs SMW): color-math subscreen-backdrop addend = the COLDATA fixed color (blue-sky/black-bg fix), and the BG tilemap palette-group offset folded into the CGRAM index (washed multi-palette art fix); undisbeliever golden stays 29/29** | | `rustysnes-apu` | SPC700 (S-SMP) + S-DSP + ARAM | **Phase 3 — SPC700 oracle 0-diff; S-DSP behavioral; integrated into the machine: the 4 `$2140-$2143` ports route through the real `Apu`, the integer-accumulator async resync clocks the SMP in **cycle-exact sub-instruction lockstep** (`68_352/715_909`, ADR 0004), SMP base-clock + timer + DSP rates ares-correct; blargg `spc_*` boot+upload+run bit-deterministically; the **timer-phase fix** (timebase/timers clocked before the write side effect, ares/Mesen2-correct) + the **DSP GAIN mode-7 threshold fix** (unsigned `hidden_env >= 0x600`, blargg/ares-correct) drive **all four `spc_*` (`spc_smp`/`spc_timer`/`spc_mem_access_times`/`spc_dsp6`) to literal `PASSED TESTS`** (asserted)** | | `rustysnes-cart` | LoROM/HiROM/ExHiROM + coprocessors | **Phase 2 base map modes + Phase 4 coprocessors: chipset-byte detection, the shared µPD77C25/µPD96050 LLE engine + DSP-1 board (real DSP-1 games with user-supplied firmware), and the Super FX/GSU — full Argonaut RISC core (`coproc::gsu`) + `SuperFxBoard` (`coproc::superfx`), host-synced on the Go flag, boots the Krom GSU suite (`superfx_oncart`). SA-1 next** | -| `rustysnes-core` | Bus + master-clock scheduler + DMA/HDMA | **Phase 2 — master-clock lockstep (6/8/12 access map), full memory decode, CPU regs + mul/div, GP-DMA + HDMA, NMI/HV-IRQ. `v0.9.0`: `rustysnes_core::controller` — Mouse/Super Scope/Super Multitap, the real 2-bit-per-clock (`data1`/`data2`) serial-shift-register protocol ported from ares' `sfc/controller/{mouse,super-scope,super-multitap}`, including WRIO (`$4201`/`$4213`) IOBIT plumbing and the Super Scope's PPU H/V-counter beam-latch (`Ppu::latch_hv_counters`); `Bus::set_port_device`/`set_mouse`/`set_superscope`/`set_multitap_pad`, save-stated (`FORMAT_VERSION` 2→3), 14 unit tests** | +| `rustysnes-core` | Bus + master-clock scheduler + DMA/HDMA | **Phase 2 — master-clock lockstep (6/8/12 access map), full memory decode, CPU regs + mul/div, GP-DMA + HDMA, NMI/HV-IRQ. `v0.9.0`: `rustysnes_core::controller` — Mouse/Super Scope/Super Multitap, the real 2-bit-per-clock (`data1`/`data2`) serial-shift-register protocol ported from ares' `sfc/controller/{mouse,super-scope,super-multitap}`, including WRIO (`$4201`/`$4213`) IOBIT plumbing and the Super Scope's PPU H/V-counter beam-latch (`Ppu::latch_hv_counters`); `Bus::set_port_device`/`set_mouse`/`set_superscope`/`set_multitap_pad`, save-stated (`FORMAT_VERSION` 2→3), 14 unit tests. `v1.2.0`: the pure `EmuCore` embedding facade relocated here from `rustysnes-frontend` (`facade` module, `std`-only, conditional `no_std` at the crate root) — load/step/framebuffer/audio/save-state, for any headless embedder; plus new `Bus::wram`/`wram_mut`/`Ppu::vram`/`vram_mut` raw memory-map accessors** | | `rustysnes-frontend` | egui shell + audio ring + pacing | **Phase 5 — PLAYABLE: native winit 0.30 + wgpu 29 + egui 0.35 + cpal shell boots real commercial ROMs with picture, sound, and control. Video: PPU BGR555→RGBA8 decode, aspect-correct (4:3) sub-rect letterbox blit. Audio: S-DSP 32 kHz FIFO → producer-side linear resampler (DRC-paced) → lock-free ring → cpal stereo stream. Input: keyboard + gilrs gamepad → `bus.set_joypad`. ROM load (+ coprocessor-firmware + `.srm` SRAM auto-load), Reset / Power-Cycle / Pause wired. wasm32 target compiles: `wasm-winit` (default) is the SAME `App`/egui shell native uses, verified end-to-end with a real headless-browser load (`docs/frontend.md` §wasm); `wasm-canvas` is a lighter, independently-functional canvas-2D fallback. Proven by the `playable_smoke` headless gate (Super Mario World: 256×224 structured frame + 63,975 non-silent samples over 120 frames) + an xvfb launch run. Save-states landed (`docs/adr/0006`: a versioned `System::save_state()`/`load_state()` envelope across every `Board` + `Cpu`/`Ppu`/`Apu`/`Bus`, proven by a round-trip determinism test), wired to a quick-save menu slot; rewind (a bounded ring buffer of full snapshots, `crate::rewind::RewindBuffer`) and run-ahead (N-frame peek-and-discard, `crate::rewind::step_with_run_ahead`) landed in `v0.3.0 "Continuum"` — both config-driven and off by default (capacity/frames `0`), proven by tests that hand-assemble a tiny 65C816 program for a real per-frame state signal. Pacing/present fixes: wall-clock fixed-timestep drive (emulation tracks the region rate, not the display refresh — fixes the ~2–3× over-speed on high-refresh panels), a real smoothed FPS meter (was hardcoded `0.0`), and a live present-mode reconfigure on the Settings → Video toggle. `v0.8.0`: Settings → Input gained a controller-port-2 peripheral selector (`config.port2_peripheral` → `Bus::set_port_device`, re-synced every frame alongside cheats/watchpoints) — correctly changes emulated behavior, but live host-input capture (a real mouse pointer, extra gamepads) is not yet wired (`docs/frontend.md` §Peripherals has the precise remaining scope). `v0.8.0` T-81-003: a Tools → Cheats… window (Game Genie / Pro Action Replay, decode in `rustysnes_core::cheat`, applied via a `Bus::read24` CPU-read intercept — not a WRAM poke, since real codes overwhelmingly target cartridge ROM) behind the `cheats` flag, native + `wasm32`. `v1.0.0`: `Board: Send` unblocks `emu-thread` to compile/test/lint clean (still off-by-default, not yet feature-parity — see `docs/frontend.md`); a disk-backed 10-slot thumbnail Save States manager (`save_states.rs`, additive alongside the quick-save slot); a working Settings → Input key-rebind grid; light/dark/system themes; fullscreen; 25%–300% speed presets (scaling both `Pacer` and the audio DRC ratio); a Performance panel with FPS/frame-time/audio-health + a rolling sparkline; a first-run welcome modal; and a `full` feature + `cargo full-build`/`full-run` aliases. `v1.0.1`: 8 per-voice audio mute checkboxes (Settings → Audio, `config.audio.voice_mutes` → `Dsp::voice_output`, no real hardware register behind it) and a fixed global keyboard hotkey table (`Escape`/`F1`-`F5`/`F9`/`F11`/`F12`/`Space`/`` ` ``, checked in `window_event` before gameplay latching, suppressed while an egui widget has keyboard focus) — everything used to be menu-bar-only. `v1.1.0`: `emu-thread` gained real audio output (a thread-owned `AudioProducer`) and a proper pause/ROM-loaded/speed lifecycle (`EmuControl`, driving a thread-owned `Pacer`) plus a `PresentBuffer` lock-free framebuffer handoff, closing its two biggest documented gaps — still not full parity with the synchronous drive (cheats/watchpoints/breakpoints/port2-peripheral/voice-mutes/run-ahead/rewind/movies/scripting/netplay-pause/RetroAchievements remain unported, `emu_thread.rs`'s own module doc has the exact list)** | | `rustysnes-netplay` | rollback netplay | **T-82-002 — GGPO-style 2-player rollback netcode, ported from RustyNES's `RollbackSession` shape (scoped to 2 remote players — a session-topology choice, not a core limitation: the core gained real Super Multitap emulation in `v0.9.0`, but rollback-netcode's 2-peer resimulation model is a separate concern from local extra-player input, matching how a netplay session and `emu-thread`'s single-player pacer already stay mutually exclusive by session type). Bit-identical resimulation proven under both ideal and adverse (latency/jitter/10% loss) conditions (`tests/determinism.rs`); a real UDP transport (OS-level loopback tested) + a wasm32-clippy-verified WebRTC transport. Frontend wiring (Tools → Netplay…, its own drive loop independent of `emu-thread`) behind the `netplay` flag is native/UDP only — the browser SDP-negotiation UI is an honestly deferred, separate scope** | | `rustysnes-cheevos` | RetroAchievements (opt-in FFI) | **T-82-003 — native FFI bridge around the vendored `rcheevos` `rc_client` C API (MIT, vendored verbatim from RustyNES's own copy, ABI-pinned via a `size_of`-vs-C-`sizeof` guard test). Achievement logic evaluates SNES WRAM only (`ra_addr_to_snes`, verified against the real `RetroAchievements/RASnes9x` integration source — cartridge SRAM is an honest scope cut). The `RustySNES/ rcheevos/` User-Agent is regression-tested. Frontend wiring (Tools → RetroAchievements… login window + a per-frame `do_frame` hook + unlock toasts) behind the `retroachievements` flag is native-only; leaderboards/rich-presence have no UI panel yet** | diff --git a/docs/architecture.md b/docs/architecture.md index a8c3055e..ed561631 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,9 +44,9 @@ rustysnes-ppu (PPU1+PPU2 — VRAM/CGRAM/OAM only) rustysnes-apu (SPC700 + S-DSP + ARAM — independent) rustysnes-cart (memory map + coprocessor families — independent) \ | / / - rustysnes-core (ties them together, re-exports public types) + rustysnes-core (ties them together, re-exports public types + the `facade` embedding API) | - rustysnes-{frontend, netplay, cheevos, script, test-harness} + rustysnes-{frontend, netplay, cheevos, script, test-harness, libretro} ``` No chip crate depends on another. `rustysnes-core` is the only crate that knows all four. @@ -103,12 +103,14 @@ gilyon / undisbeliever ROMs, blargg's `spc_*` for audio, and the 240p Suite for | `rustysnes-cheevos` | RetroAchievements (opt-in, native FFI). | | `rustysnes-script` | Lua scripting / TAS API. | | `rustysnes-test-harness` | Golden-log differ, `run_until_complete`, JSON-oracle runner, screenshot baseline. | +| `rustysnes-libretro` | A libretro core (`v1.2.0`) — a thin C-ABI wrapper over `rustysnes-core::facade::EmuCore`, loadable by RetroArch. See `docs/libretro.md`. | The chip stack is `#![no_std]` + `extern crate alloc;`; `rustysnes-core` is conditionally so (`#![cfg_attr(not(feature = "std"), no_std)]`, `v1.2.0`) — its default `std` feature enables the `facade` module, and disabling it (the `thumbv7em` no_std CI gate) restores unconditional `no_std`, proving the facade compiles out entirely rather than merely going unused. Only -`rustysnes-frontend` and `rustysnes-cheevos` (FFI) carry `unsafe` (each with a `// SAFETY:` +`rustysnes-frontend`, `rustysnes-cheevos`, and `rustysnes-libretro` (all FFI) carry `unsafe` +(each with a `// SAFETY:` comment). ## Architectural alternatives (rejected) diff --git a/docs/libretro.md b/docs/libretro.md new file mode 100644 index 00000000..09001d2d --- /dev/null +++ b/docs/libretro.md @@ -0,0 +1,113 @@ +# Libretro core — RustySNES + +**References:** `docs/architecture.md` §3/§6 (the `rustysnes-core::facade` embedding surface); +`crates/rustysnes-libretro/src/lib.rs`'s own module doc. + +## Purpose + +`rustysnes-libretro` (`v1.2.0`) is a thin C-ABI wrapper exposing `rustysnes-core`'s +[`facade::EmuCore`](../crates/rustysnes-core/src/facade.rs) — the same relocated pure emulation +facade `rustysnes-frontend`'s own `EmuCore` wraps — as a [libretro](https://www.libretro.com/) +core, loadable by RetroArch or any other libretro-compatible frontend. It duplicates zero +emulation logic; every hook (`on_run`, `on_serialize`, `get_memory_data`, …) is a thin translation +between the libretro C ABI and `EmuCore`'s existing safe Rust API. + +## Building + +```bash +cargo build -p rustysnes-libretro --release +``` + +Produces `librustysnes_libretro.so` (Linux) / `.dylib` (macOS) / `.dll` (Windows) under +`target/release/`, plus a static `.a`/`.lib` (the crate's `crate-type` is +`["cdylib", "staticlib"]`). Needs a working `clang`/`libclang` at build time +(`rust-libretro-sys`'s `bindgen` build script) — present by default on GitHub-hosted CI runners +and most desktop Linux/macOS toolchains; on Windows, install LLVM and ensure `libclang.dll` is on +`PATH` if the build script can't find it. + +## Manual RetroArch verification + +No automated RetroArch integration test exists yet (this would need a real RetroArch binary + +headless display in CI, out of this ticket's scope) — verify manually after any change to this +crate: + +1. Build the core: `cargo build -p rustysnes-libretro --release`. +2. Copy `target/release/librustysnes_libretro.so` (or platform equivalent) into RetroArch's + `cores/` directory, or point RetroArch's "Load Core" dialog at it directly. +3. Load a ROM (`.sfc`/`.smc`/`.swc`/`.fig`) via RetroArch's content browser — confirm: + - Picture renders at the correct resolution (256×224 NTSC / 256×239 PAL — RetroArch's on-screen + display should reflect the corrected geometry within the first second, once + `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO` fires from the first `on_run`) and colors are correct + (a botched R/B channel swap is the most likely regression if this is ever touched — check a + scene with distinct red and blue elements). + - Audio plays without popping/pitch artifacts (confirms the `EmuCore::audio()` → interleaved + `i16` batch path). + - P1/P2 standard controller input responds (D-pad + all 8 face/shoulder buttons). + - Save states work: RetroArch's quick-save/quick-load (`F2`/`F4` by default) round-trips + correctly (confirms `get_serialize_size`/`on_serialize`/`on_unserialize`). + - SRAM persists across a RetroArch restart for a battery-backed cart (confirms + `get_memory_data`/`get_memory_size` for `RETRO_MEMORY_SAVE_RAM` — RetroArch reads/writes this + pointer directly, there is no separate `.srm` file management in this core unlike the native + frontend). + - A coprocessor cart (e.g. a DSP-1 title) either works correctly (if the firmware dump is + present in RetroArch's configured system directory, named per + `EmuCore::firmware_candidates()`) or logs a clear warning to RetroArch's core log instead of + silently misbehaving. +4. Try a Game Genie or Pro Action Replay code via RetroArch's Cheats menu — confirm it applies + (`on_cheat_set`) and that disabling it / resetting cheats (`on_cheat_reset`) reverts cleanly. + +## Known scope cuts (documented, not silent gaps) + +- **Peripheral negotiation** (`RETRO_DEVICE_SUBCLASS`): only the standard 12-button joypad is + wired for P1/P2. `EmuCore::set_mouse`/`set_superscope`/`set_multitap_pad` already exist + core-side (the native frontend uses them), but this libretro core does not yet negotiate + `RETRO_DEVICE_MOUSE`/a Super Scope subclass/Multitap sub-ports via + `on_set_controller_port_device` + `RETRO_ENVIRONMENT_SET_CONTROLLER_INFO`. A real, open + follow-up — not attempted here to keep this ticket's scope to a correctly-working standard-pad + core first. +- **No automated RetroArch smoke test in CI** — see "Manual RetroArch verification" above. CI + instead: (a) the `lint` job's `cargo clippy -p rustysnes-libretro` (implicitly, via + `--workspace`) plus an explicit `cargo build -p rustysnes-libretro` (proves the cdylib/staticlib + actually links — the main FFI-crate-specific risk); (b) `full-test`'s workspace-wide + test/clippy/doc gate at tag-push time. +- **Region/timing correction is one-shot, not per-`on_run`**: `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO` + is only callable from a `RunContext` (this binding's own restriction, not a libretro-spec one), + so the core defers the NTSC-default → real-region correction to the first `on_run` after + `on_load_game`, using a `pending_av_info` flag. A region-hotswap cart (there is no such real SNES + cart) or a future re-region feature would need a different mechanism — out of scope today. + +## SNES-specific deltas from a hypothetical NES-libretro template + +(`rustynes-libretro`, the sibling NES core, was the direct porting reference — see that crate's +own `src/lib.rs` for the shared skeleton this one adapted.) + +- **Region-aware geometry + timing**: NTSC 256×224 @ 60.0988 Hz vs. PAL 256×239 @ 50.007 Hz (vs. + a single fixed NES geometry) — corrected post-load, see "Known scope cuts" above. +- **`sample_rate: 32000.0`**, not the NES core's 44100 — the S-DSP's real, fixed output rate + (`docs/apu.md`), matching `rustysnes-frontend::audio_core::SDSP_RATE`'s own established + constant. +- **`EmuCore::audio()` already emits signed 16-bit stereo pairs** — no `f32`→`i16` scaling dance + needed (unlike an APU that emits floats), just interleave-and-batch. +- **Coprocessor firmware auto-resolution** (`RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` → + `EmuCore::firmware_candidates`/`install_firmware`) — no NES equivalent; the µPD77C25 DSP-1..4 + family and CX4 need an external firmware dump this core tries to locate automatically. +- **Cheat support** (`on_cheat_set`/`on_cheat_reset` → `rustysnes_core::cheat::decode` → + `Bus::set_cheats`) — wired here; had no template to follow since the NES sibling core doesn't + implement libretro cheat support. +- **New `Bus::wram_mut`/`Ppu::vram_mut`/`Cart::sram_mut` accessors** (`v1.2.0`) — added + specifically for this crate's `get_memory_data`; previously only word-at-a-time debug accessors + (`peek_wram`/`vram_word`) or a copy-based `load_sram` existed. + +## `retro_game_info` is opaque in `rust-libretro-sys` 0.3.2 — use `GET_GAME_INFO_EXT` instead + +`on_load_game`'s `game: Option` parameter is **unusable** with this pinned +version: bindgen generates `retro_game_info` as a 1-byte opaque placeholder (`pub _address: u8`) +rather than the real 4-field C struct (verified against the crate's own generated +`bindings_libretro.rs` and its self-inconsistent `bindgen_test_layout_retro_game_info` test, which +asserts a 32-byte size against a 1-byte type — the test would fail if it ever actually ran). The +proven workaround (same one `rustynes-libretro` already uses successfully): fetch the ROM via +`RETRO_ENVIRONMENT_GET_GAME_INFO_EXT` through the raw environment callback, using a hand-rolled +`#[repr(C)]` `RetroGameInfoExt` struct that mirrors libretro.h's real layout directly, bypassing +the broken opaque binding entirely. If a future `rust-libretro`/`rust-libretro-sys` upgrade fixes +this upstream, `on_load_game` could be simplified back to the straightforward +`game.unwrap().data`/`.size` form — check the generated bindings first. diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index f3a71ce9..f8e70360 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -37,7 +37,11 @@ record; this file frames the phase line. RetroAchievements) as a documented follow-up; also fixes a real, independent `SuperFxBoard::map` open-bus bug and investigates (without landing code for) the harder open-bus-via-DMA-latch bug, DRAM refresh timing, and the fractional-timebase refactor's own - go/no-go gate — see `to-dos/VERSION-PLAN.md`'s `v1.1.0` section for the full breakdown. Save-states are **fully + go/no-go gate — see `to-dos/VERSION-PLAN.md`'s `v1.1.0` section for the full breakdown. + **`v1.2.0`** (in progress) relocates the pure `EmuCore` embedding facade into a new `std`-only + `rustysnes_core::facade` module and lands `rustysnes-libretro`, a real libretro core wrapping it + (region-aware NTSC/PAL, cheats, coprocessor firmware auto-resolution, raw memory-map pointers — + `docs/libretro.md`); a CRT/HQ2x shader pipeline is next. Save-states are **fully implemented** (`v0.2.0 "Persistence"`, `docs/adr/0006` — every subsystem round-trips its exact state through one versioned envelope, proven by a round-trip determinism test), and rewind + run-ahead (`v0.3.0 "Continuum"`, `crate::rewind` — a bounded ring buffer of full snapshots + diff --git a/to-dos/VERSION-PLAN.md b/to-dos/VERSION-PLAN.md index 905a9e15..5e5e0f6a 100644 --- a/to-dos/VERSION-PLAN.md +++ b/to-dos/VERSION-PLAN.md @@ -676,6 +676,33 @@ gate; README/CHANGELOG/`docs/`/`docs/STATUS.md` fully in sync. pre-existing broken intra-doc links from `v1.0.1`'s per-voice-mute work along the way), and both wasm32 frontends all green with zero regressions. +### v1.2.0 — Libretro core + CRT/HQ2x shader pipeline (in progress) + +- **`EmuCore` facade relocation — DONE.** The pure emulation-core facade (`load_rom`/`reset`/ + `power_cycle`/`run_frame`/`present_current_frame`/`framebuffer`/`audio`/`save_state`/ + `load_state`/the `set_*` peripheral feeds) moved from `rustysnes-frontend::emu` into a new, + `std`-only `rustysnes_core::facade` module — a libretro core or any other headless embedder + depends on `rustysnes-core` alone. `rustysnes-frontend::emu::EmuCore` is now a thin wrapper + keeping only the debugger-only fields. Zero behavior change; also fixed a determinism-seed- + discarding bug found in review (`load_rom`/`power_cycle`/`close_rom` rebuilt `System::new(0)` + instead of preserving the constructor's seed). See `docs/architecture.md` §3/§6. +- **`rustysnes-libretro` — DONE.** A libretro core wrapping the relocated facade: region-aware + NTSC/PAL geometry+timing (corrected via `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO` on the first + `on_run` after a ROM loads, once the cart header's region byte is known), the S-DSP's real + 32 kHz sample rate, coprocessor firmware auto-resolution from the frontend's system directory, + Game Genie/Pro Action Replay cheat support (`on_cheat_set`/`on_cheat_reset`), and raw WRAM/ + VRAM/SRAM memory-map pointers (`get_memory_data`/`get_memory_size`) for RetroArch's own SRAM + autosave and RetroAchievements/cheat tooling. New additive `Bus::wram`/`wram_mut`, + `Ppu::vram`/`vram_mut`, `Cart::sram_mut` accessors support it. Peripheral negotiation (Mouse/ + Super Scope/Multitap via `RETRO_DEVICE_SUBCLASS`) is a documented follow-up, not yet wired. See + `docs/libretro.md`. +- **CRT/HQ2x shader pipeline — not started.** +- **Regression gate (facade + libretro so far):** full workspace suite, the full clippy matrix + (default / flags-off / `full`), the `--features test-roms` accuracy/oracle battery, the + `no_std` build, the doc-warnings gate, and both wasm32 frontends all green with zero + regressions; the new `rustysnes-libretro` crate itself builds/links clean as both `cdylib` and + `staticlib`, with every required libretro C-ABI symbol confirmed exported. + ## Post-v1.0 — Reach (deferred) - **Libretro core**, a **shader/filter pipeline** (CRT/HQ2x), **HD texture packs** (wires the From 430f99471b29183b813680832640baa9ad00fe89 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 11 Jul 2026 05:25:13 -0400 Subject: [PATCH 3/3] fix(libretro): address PR #63 review findings - on_load_game: pass the ROM byte slice straight to EmuCore::load_rom instead of an intermediate .to_vec() -- load_rom already copies the bytes into its own retained storage, so the extra copy was pure waste for what can be a multi-MB ROM. - get_serialize_size/on_serialize: assign core.save_state()'s Vec directly instead of extend_from_slice-ing it into a second buffer -- save_state() already allocates fresh each call, so there was no capacity to reuse, just a redundant full-snapshot copy on a path RetroArch's own rewind feature can hit once per frame. - on_load_game's GET_GAME_INFO_EXT environment-callback fetch: replace .unwrap() with a graceful Err return if the frontend provides no callback (a spec violation, but still external input this crate shouldn't panic on). - Verified the unused-libc claim is a false positive: rust_libretro's input_descriptors! macro expands to code referencing libc::c_char in the calling crate's scope, so it must stay a direct dependency here even though nothing in this crate's own source names it (confirmed by actually removing it: cargo check immediately failed to resolve the macro expansion). Left in place with an explanatory comment. Co-Authored-By: Claude Sonnet 5 --- crates/rustysnes-libretro/Cargo.toml | 4 ++++ crates/rustysnes-libretro/src/lib.rs | 29 +++++++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/rustysnes-libretro/Cargo.toml b/crates/rustysnes-libretro/Cargo.toml index 04f4eca0..478db20f 100644 --- a/crates/rustysnes-libretro/Cargo.toml +++ b/crates/rustysnes-libretro/Cargo.toml @@ -15,6 +15,10 @@ crate-type = ["cdylib", "staticlib"] workspace = true [dependencies] +# Not referenced directly in this crate's own source, but required: `rust_libretro:: +# input_descriptors!` expands to code referencing `libc::c_char` in the CALLING crate's scope +# (confirmed via the macro's definition in `rust-libretro`'s own `macros.rs`), so this must stay a +# direct dependency here even though `grep`-ing this crate's `src/` alone won't show a use of it. libc = "0.2" rust-libretro = "0.3.2" diff --git a/crates/rustysnes-libretro/src/lib.rs b/crates/rustysnes-libretro/src/lib.rs index e84534b2..b95622f4 100644 --- a/crates/rustysnes-libretro/src/lib.rs +++ b/crates/rustysnes-libretro/src/lib.rs @@ -251,7 +251,14 @@ impl Core for RustySnesLibretro { // fetched directly through the raw environment callback. let ext_info = unsafe { let generic_ctx: GenericContext = (&*ctx).into(); - let cb = generic_ctx.environment_callback().unwrap(); + // A `None` callback here would mean the frontend never called `retro_set_environment` + // before `retro_load_game` — a libretro spec violation, but external/untrusted input + // to this crate nonetheless; fail gracefully via the caller's `Result` rather than + // panicking (`rust-libretro`'s own examples `.unwrap()` here, but this project's own + // convention is to never panic on data an external frontend controls). + let Some(cb) = *generic_ctx.environment_callback() else { + return Err("frontend provided no environment callback (spec violation)".into()); + }; 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 @@ -279,11 +286,12 @@ impl Core for RustySnesLibretro { // references a valid, contiguous byte slice of exactly `size` bytes, owned by the // frontend for the duration of this call. let rom_bytes = - unsafe { std::slice::from_raw_parts(ext_info.data.cast::(), ext_info.size) } - .to_vec(); + unsafe { std::slice::from_raw_parts(ext_info.data.cast::(), ext_info.size) }; let mut core = EmuCore::new(0, Region::Ntsc); - core.load_rom(&rom_bytes) + // `load_rom` already copies the ROM into its own retained storage (for Power-Cycle) — + // passing the borrowed slice directly avoids a redundant extra full-ROM copy here. + core.load_rom(rom_bytes) .map_err(|e| format!("failed to load ROM: {e}"))?; // Auto-resolve coprocessor firmware (DSP-1..4/CX4) from the frontend's system directory — @@ -313,9 +321,9 @@ impl Core for RustySnesLibretro { } } - let mut tmp = Vec::new(); - tmp.extend_from_slice(&core.save_state()); - self.serialize_size = tmp.len(); + // `save_state()` already returns a freshly allocated `Vec` — just measure it directly + // instead of copying it again into a second buffer only to read its length. + self.serialize_size = core.save_state().len(); self.core = Some(core); self.pending_av_info = true; @@ -479,8 +487,11 @@ impl Core for RustySnesLibretro { let Some(core) = self.core.as_ref() else { return false; }; - self.serialize_buffer.clear(); - self.serialize_buffer.extend_from_slice(&core.save_state()); + // `save_state()` already allocates a fresh `Vec` internally, so there is no capacity to + // reuse by copying into `serialize_buffer` via `extend_from_slice` — assign it directly + // instead of paying for a second full-snapshot copy (RetroArch's own rewind feature can + // call this once per frame, making the extra copy a genuine hot-path cost). + self.serialize_buffer = core.save_state(); if slice.len() >= self.serialize_buffer.len() { slice[..self.serialize_buffer.len()].copy_from_slice(&self.serialize_buffer); true