diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3494bcd..37aa8ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,14 @@ jobs: # Full-gate cadence only (see `setup` above) — cross-compiling the whole chip stack is cheap but # doesn't need to happen on every single PR push. + # + # `v1.14.0 "Foundry"`: expanded from a single `rustysnes-core --no-default-features` build into + # a per-crate matrix. The old single job only proved the no_std posture TRANSITIVELY (every + # chip crate is already `default-features = false` in `rustysnes-core`'s own `Cargo.toml`, and + # `#![no_std]` is unconditional -- not feature-gated -- in each chip crate's `lib.rs`), never as + # its own standalone build target. This matrix builds each chip crate directly, so a future + # accidental `std`-only dependency added to just one of them fails on ITS OWN row instead of + # only surfacing (if at all) through `rustysnes-core`'s aggregate build. no_std: needs: setup if: needs.setup.outputs.mode == 'full' @@ -212,13 +220,21 @@ jobs: CARGO_NET_RETRY: "10" CARGO_HTTP_MULTIPLEXING: "false" runs-on: ubuntu-latest + strategy: + matrix: + crate: + - rustysnes-cpu + - rustysnes-ppu + - rustysnes-apu + - rustysnes-cart + - rustysnes-core steps: - uses: actions/checkout@v7 - uses: ./.github/actions/rust-setup with: targets: thumbv7em-none-eabihf - cache-key-suffix: no_std - - run: cargo build -p rustysnes-core --target thumbv7em-none-eabihf --no-default-features + cache-key-suffix: no_std-${{ matrix.crate }} + - run: cargo build -p ${{ matrix.crate }} --target thumbv7em-none-eabihf --no-default-features # Full-gate cadence only (`v1.0.0`): a release-mode Criterion build + run is too costly for every # PR push, and the frame-time gate is an absolute ceiling that only needs to hold at diff --git a/CHANGELOG.md b/CHANGELOG.md index f09f3b1..6166c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **New crate `rustysnes-mobile`** (`v1.14.0 "Foundry"`, Mobile Phase 1): a `UniFFI` bridge + generating Kotlin (Android) and Swift (iOS) bindings over `rustysnes_core::facade::EmuCore` — + the same facade the desktop frontend and `rustysnes-libretro` already drive the emulator + through. MVP surface: ROM load/close, `run_frame`, the peripheral setters (Gamepad/Mouse/Super + Scope/Multitap), framebuffer + per-frame audio access, save/load state, reset/power-cycle. + Verified for + real: a genuine `cargo ndk` cross-compile to `arm64-v8a` produced an actual ARM64 `.so` + (confirmed via `file`), and `uniffi-bindgen` generated real, correctly-shaped Kotlin and Swift + bindings from the compiled library. +- **`no_std` CI gate expanded to a per-crate matrix**: `rustysnes-{cpu,ppu,apu,cart,core}` each + now build standalone against `thumbv7em-none-eabihf --no-default-features`, replacing the prior + single aggregate-only `rustysnes-core` build. +- **The mobile/Android+iOS "no appetite" default from `v1.0.0` is formally reversed** — new + `docs/adr/0012-mobile-platform-target.md` records the decision, new `docs/mobile-readiness.md` + is the living status page. + +### Deferred (honestly scoped, not silently dropped) + +- HD-pack consumption, cheats, rewind/run-ahead, netplay, `RetroAchievements`, and Lua/TAS + scripting are all out of `rustysnes-mobile`'s MVP surface — real, separate frontend concerns + layered on top of `EmuCore` in the desktop build too, not re-invented here. +- No real Android app, emulator run, or touch UX yet — `v1.15.0 "Sideload"`'s scope. +- No iOS build/link/run at all — this development environment has no macOS/Xcode toolchain. + `v1.16.0 "Beacon"`'s `rustysnes-ios` crate and SwiftUI shell will be written and Rust-side + compile-checked, but the real Xcode verification needs the project owner's own Mac. + ## [1.13.0] "Vantage" - 2026-07-12 Ninth release of the RustyNES-parity roadmap: two accessibility theme variants, plus an honest diff --git a/Cargo.lock b/Cargo.lock index bfc7ed6..19f60e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,48 @@ dependencies = [ "libloading", ] +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.2", + "serde", + "serde_derive", + "syn 2.0.118", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + [[package]] name = "atomic" version = "0.6.1" @@ -242,6 +284,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.63.0" @@ -407,6 +458,38 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cast" version = "0.3.0" @@ -1319,6 +1402,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1485,6 +1577,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -2928,7 +3031,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -3627,7 +3730,7 @@ dependencies = [ "rustysnes-script", "serde", "thiserror 2.0.18", - "toml", + "toml 1.1.2+spec-1.1.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -3650,6 +3753,16 @@ dependencies = [ "rustysnes-core", ] +[[package]] +name = "rustysnes-mobile" +version = "1.13.0" +dependencies = [ + "rustysnes-cart", + "rustysnes-core", + "thiserror 2.0.18", + "uniffi", +] + [[package]] name = "rustysnes-netplay" version = "1.13.0" @@ -3727,6 +3840,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -3751,6 +3884,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3886,6 +4023,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.3" @@ -3923,6 +4066,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "smithay-client-toolkit" version = "0.19.2" @@ -4133,7 +4282,7 @@ dependencies = [ "phf", "sha2", "signal-hook", - "siphasher", + "siphasher 1.0.3", "terminfo", "termios", "thiserror 1.0.69", @@ -4148,6 +4297,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4254,6 +4412,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4266,7 +4433,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4287,7 +4454,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4296,7 +4463,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4389,6 +4556,127 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "uniffi" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.5.11", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +dependencies = [ + "anyhow", + "siphasher 0.3.11", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4748,6 +5036,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -5303,6 +5600,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 134e794..a413d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/rustysnes-apu", "crates/rustysnes-cart", "crates/rustysnes-gfx-shaders", + "crates/rustysnes-mobile", "crates/rustysnes-netplay", "crates/rustysnes-cheevos", "crates/rustysnes-script", diff --git a/crates/rustysnes-mobile/Cargo.toml b/crates/rustysnes-mobile/Cargo.toml new file mode 100644 index 0000000..13e2e9b --- /dev/null +++ b/crates/rustysnes-mobile/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "rustysnes-mobile" +description = "RustySNES: UniFFI mobile bridge (Kotlin/Swift bindings over the emulation core)" +version = "1.13.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["snes", "emulator", "mobile", "uniffi", "android"] +categories = ["emulators", "games"] + +[lib] +# `cdylib` is what Android's JNI loader (`System.loadLibrary`) and iOS's `.xcframework` packaging +# both need; `staticlib` covers iOS static linking; `rlib` keeps normal crate-graph testing/ +# `cargo test` on the host working without a mobile toolchain. +crate-type = ["cdylib", "staticlib", "rlib"] + +[features] +# Opt-in only (found in review, PR #99): the `uniffi-bindgen` binary (`src/bin/uniffi-bindgen.rs`) +# needs `uniffi`'s `cli` feature (pulls in `askama`/`clap`/`goblin` -- host-only binding-generator +# tooling, never invoked at runtime on a mobile device). Gating it behind this feature keeps every +# real mobile build (Android/iOS `cargo ndk`/`cargo build --target aarch64-apple-ios`, and the +# `cdylib`/`staticlib` artifacts those produce) off that dependency graph entirely -- only +# `cargo run -p rustysnes-mobile --features bindgen --bin uniffi-bindgen -- ...` (host tooling) +# pulls it in. +bindgen = ["uniffi/cli"] + +[[bin]] +name = "uniffi-bindgen" +required-features = ["bindgen"] + +[lints] +workspace = true + +[dependencies] +# `default-features = true` (i.e. `std` on) -- `rustysnes_core::facade::EmuCore` is `std`-only +# (needs `zip` archive extraction), matching `rustysnes-libretro`'s identical dependency shape. +# Both Android's and iOS's Rust targets are `std`-supporting Tier 2/3 targets, not bare-metal, so +# there is no `no_std` constraint here the way there is for the chip crates. +rustysnes-core = { path = "../rustysnes-core", version = "1.4.0" } +rustysnes-cart = { path = "../rustysnes-cart", version = "1.4.0" } +uniffi = "0.29" +thiserror = "2.0" diff --git a/crates/rustysnes-mobile/src/bin/uniffi-bindgen.rs b/crates/rustysnes-mobile/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000..600cbea --- /dev/null +++ b/crates/rustysnes-mobile/src/bin/uniffi-bindgen.rs @@ -0,0 +1,10 @@ +//! Standalone binding-generator binary — `cargo run -p rustysnes-mobile --features bindgen +//! --bin uniffi-bindgen -- generate --library --language kotlin --out-dir ` +//! produces the Kotlin (or Swift) bindings for [`rustysnes_mobile`]'s `#[uniffi::export]` +//! surface. `--features bindgen` is required (`required-features` in `Cargo.toml`) — the CLI +//! machinery it pulls in (`askama`/`clap`/`goblin`) is host-only tooling, never shipped to a +//! mobile build; see `docs/mobile-readiness.md` for the full binding-generation workflow. + +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/crates/rustysnes-mobile/src/lib.rs b/crates/rustysnes-mobile/src/lib.rs new file mode 100644 index 0000000..079eb76 --- /dev/null +++ b/crates/rustysnes-mobile/src/lib.rs @@ -0,0 +1,420 @@ +//! `UniFFI` mobile bridge (`v1.14.0 "Foundry"`, Mobile Phase 1) — generates Kotlin (Android, +//! `v1.15.0 "Sideload"`) and Swift (iOS, `v1.16.0 "Beacon"`) bindings over +//! [`rustysnes_core::facade::EmuCore`], the same `std`-only facade `rustysnes-frontend` and +//! `rustysnes-libretro` already drive the emulator through — this crate adds no new emulation +//! logic of its own, only an FFI-safe wrapper around the existing facade API. +//! +//! # Why this shape +//! +//! `rustysnes_cart::Board: Send` since `v1.0.0`, and every chip crate (`rustysnes-{cpu,ppu,apu, +//! cart}`) has been `#![no_std]` + `alloc` since before that — both prerequisites this crate +//! depends on were already proven, not something landing here for the first time. +//! [`EmuCore`] itself is `std`-only (needs `zip` archive +//! extraction for `.zip`-wrapped ROMs), which is fine: Android's and iOS's Rust targets are both +//! `std`-supporting (Tier 2/3), not bare-metal, unlike the `thumbv7em-none-eabihf` `no_std` CI +//! gate the chip crates are proven against. +//! +//! # MVP surface (`v1.14.0`) +//! +//! ROM load/close, `run_frame`, the peripheral setters (wrapping `rustysnes_core::controller`), +//! framebuffer + audio drain, save/load state, reset/power-cycle. Deliberately NOT in scope this +//! release (see `to-dos/VERSION-PLAN.md`'s `v1.14.0` entry for the full honest-deferral list): +//! HD-pack consumption, cheats, rewind/run-ahead, netplay, `RetroAchievements`, Lua/TAS scripting — +//! every one of those is a real, separate frontend concern layered on top of `EmuCore` in the +//! desktop build too, not something this bridge needs to re-invent to reach a playable MVP. +//! +//! # Threading +//! +//! [`MobileCore`] wraps its `EmuCore` in a [`std::sync::Mutex`] so the generated bindings are +//! `Send + Sync` (a `UniFFI` `Object` requirement) even though nothing here actually drives the +//! emulator from more than one thread at a time in practice — the mobile shell (Kotlin/Swift) +//! calls every method from its own single render/audio-callback thread, exactly like the desktop +//! frontend's synchronous (non-`emu-thread`) render path does. + +uniffi::setup_scaffolding!(); + +use std::sync::{Arc, Mutex}; + +use rustysnes_cart::Region as CartRegion; +use rustysnes_core::controller::PortDevice; +use rustysnes_core::facade::EmuCore; + +/// The console region. +/// +/// Mirrors [`rustysnes_cart::Region`], re-exposed as its own `UniFFI` enum so the generated +/// Kotlin/Swift bindings don't need to know about the cart crate's own type, matching the +/// frontend's own `crate::config::Region` re-derivation pattern. +#[derive(uniffi::Enum, Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MobileRegion { + /// 60.0988 Hz, 224 active scanlines. + #[default] + Ntsc, + /// 50.007 Hz, 239 active scanlines. + Pal, +} + +impl From for CartRegion { + fn from(r: MobileRegion) -> Self { + match r { + MobileRegion::Ntsc => Self::Ntsc, + MobileRegion::Pal => Self::Pal, + } + } +} + +/// Which peripheral occupies a controller port — mirrors +/// [`rustysnes_core::controller::PortDevice`]. +#[derive(uniffi::Enum, Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MobilePortDevice { + /// The standard SNES pad (default, both ports). + #[default] + Gamepad, + /// SNES Mouse. + Mouse, + /// Super Scope light gun. + SuperScope, + /// Super Multitap (4 sub-pads). + Multitap, +} + +impl From for PortDevice { + fn from(d: MobilePortDevice) -> Self { + match d { + MobilePortDevice::Gamepad => Self::Gamepad, + MobilePortDevice::Mouse => Self::Mouse, + MobilePortDevice::SuperScope => Self::SuperScope, + MobilePortDevice::Multitap => Self::Multitap, + } + } +} + +/// The active framebuffer's dimensions. +/// +/// [`EmuCore::fb_dims`] as an FFI-safe record instead of a bare tuple (`UniFFI` has no tuple +/// type; a named record is also clearer at the Kotlin/Swift call site than a positional pair). +#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameSize { + /// Active framebuffer width in pixels. + pub width: u32, + /// Active framebuffer height in pixels. + pub height: u32, +} + +/// Errors this bridge can surface across the FFI boundary. +/// +/// Both variants wrap the underlying facade error's `Display` text rather than re-deriving +/// structured detail — a mobile UI shows this as a toast/dialog string, it doesn't need to +/// pattern-match a specific failure reason the way `rustysnes-frontend`'s own richer error +/// handling might. +#[derive(uniffi::Error, Debug, thiserror::Error)] +pub enum MobileError { + /// [`EmuCore::load_rom`] failed (empty image, bad header, or a corrupt/unrecognized zip). + #[error("failed to load ROM: {0}")] + RomLoad(String), + /// [`EmuCore::load_state`] failed (bad magic, unsupported `FORMAT_VERSION`, or truncated + /// data). + #[error("failed to load save state: {0}")] + StateLoad(String), +} + +/// The emulator handle exposed to Kotlin/Swift — one instance per emulation session, wrapping +/// exactly one [`EmuCore`]. See the module doc for why this is `Mutex`-wrapped. +#[derive(uniffi::Object)] +pub struct MobileCore(Mutex); + +#[uniffi::export] +impl MobileCore { + /// Construct a fresh, ROM-less core for `region`. Matches [`EmuCore::new`]'s own + /// power-on-seed convention (a fixed constant, not host randomness — determinism, `docs/adr/ + /// 0004`, applies identically on mobile). + #[uniffi::constructor] + #[must_use] + pub fn new(region: MobileRegion) -> Arc { + Arc::new(Self(Mutex::new(EmuCore::new( + 0x5A5A_5A5A_5A5A_5A5A, + region.into(), + )))) + } + + /// Load a ROM image (raw `.sfc`/`.smc`/`.fig`/`.swc` bytes, or a `.zip` wrapping one — see + /// [`EmuCore::load_rom`]'s own doc for the transparent-unzip behavior). + /// + /// # Errors + /// [`MobileError::RomLoad`] on an empty image, bad header, or corrupt/unrecognized zip. + // `#[uniffi::export]` needs an owned buffer here -- the generated Kotlin/Swift bindings + // marshal a `ByteArray`/`Data` across the FFI boundary into an owned `Vec`, not a borrow + // with a lifetime the generated glue code has no way to express. + #[allow(clippy::needless_pass_by_value)] + pub fn load_rom(&self, rom: Vec) -> Result<(), MobileError> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .load_rom(&rom) + .map_err(|e| MobileError::RomLoad(e.to_string())) + } + + /// Close the currently loaded ROM (present a blank/inert core). A no-op if none is loaded. + pub fn close_rom(&self) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .close_rom(); + } + + /// Whether a ROM is currently loaded. + #[must_use] + pub fn rom_loaded(&self) -> bool { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .rom_loaded() + } + + /// Soft-reset the console (the cart's reset vector, RAM contents preserved). + pub fn reset(&self) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reset(); + } + + /// Hard-reset (power-cycle) the console (RAM re-seeded from the deterministic power-on + /// pattern, matching real hardware's own inconsistent-but-reproducible SRAM state). + pub fn power_cycle(&self) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .power_cycle(); + } + + /// Run exactly one emulated frame. + pub fn run_frame(&self) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .run_frame(); + } + + /// The current RGBA8 framebuffer, copied out (sized to [`Self::frame_size`]'s active + /// sub-rect within the backing hi-res-worst-case allocation — see [`EmuCore::framebuffer`]'s + /// own doc for the sub-rect convention the mobile shell's texture upload must respect). + #[must_use] + pub fn framebuffer(&self) -> Vec { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .framebuffer() + .to_vec() + } + + /// The active framebuffer's `(width, height)`. + #[must_use] + pub fn frame_size(&self) -> FrameSize { + let (width, height) = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fb_dims(); + FrameSize { width, height } + } + + /// The current frame's buffered audio as interleaved `[L, R, L, R, ...]` 16-bit PCM samples, + /// ready for a mobile audio callback (`AAudio`/`AVAudioEngine`) to consume directly. + /// + /// **Non-destructive, call exactly once per [`Self::run_frame`]** — this mirrors + /// [`EmuCore::audio`]'s own contract exactly (every existing consumer, `rustysnes-frontend`'s + /// synchronous render path and its `emu-thread` build alike, reads `audio()` the same way): + /// `run_frame` clears the buffer at its own start and refills it with exactly that frame's + /// samples, so this returns fresh data once per `run_frame` call but the SAME data if called + /// again before the next one (not a FIFO/pop-style drain despite the method's name, which is + /// kept only because "drain" reads more naturally as a mobile-callback verb than "audio"). + /// + /// Returns a freshly allocated `Vec` rather than filling a caller-supplied buffer: a + /// `#[uniffi::export]` method cannot take a `&mut` parameter (only `&self`/`&`/owned -- + /// `uniffi::export`'s generated FFI glue always lowers a `&mut T` into a plain `&T`, so a + /// `&mut Vec` out-parameter fails to type-check against this method's own body, found by + /// actually trying it). The capacity is pre-sized to the exact known sample count so `push` + /// never reallocates mid-loop, which is the real, UniFFI-compatible half of the perf win. + // The lock genuinely must stay held for the whole body -- `audio()` returns a slice borrowed + // from the guard, and every byte of it has to be copied out while that borrow is still valid. + // There's no earlier point to drop the guard at; the lint's usual "shrink the critical + // section" fix doesn't apply here. + #[allow(clippy::significant_drop_tightening)] + #[must_use] + pub fn drain_audio(&self) -> Vec { + let guard = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let audio = guard.audio(); + let mut out = Vec::with_capacity(audio.len() * 2); + for &(l, r) in audio { + out.push(l); + out.push(r); + } + out + } + + /// Set player `player`'s (`0` or `1`) standard-gamepad button state (see + /// [`EmuCore::set_pad`]'s own doc for the SNES button-bit layout). + pub fn set_pad(&self, player: u8, buttons: u16) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_pad(player as usize, buttons); + } + + /// Select which peripheral occupies controller port `port` (`0` or `1`). + pub fn set_port_device(&self, port: u8, device: MobilePortDevice) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_port_device(port as usize, device.into()); + } + + /// Update the Mouse peripheral's relative-motion + button state on port `port`. + pub fn set_mouse(&self, port: u8, dx: i32, dy: i32, left: bool, right: bool) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_mouse(port as usize, dx, dy, left, right); + } + + /// Update the Super Scope's screen-space aim position + button state on port `port`. + pub fn set_superscope(&self, port: u8, x: i32, y: i32, buttons: u8) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_superscope(port as usize, x, y, buttons); + } + + /// Update one Super Multitap sub-pad's (`sub_index` `0..=3`) button state on port `port`. + pub fn set_multitap_pad(&self, port: u8, sub_index: u8, buttons: u16) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_multitap_pad(port as usize, sub_index as usize, buttons); + } + + /// Serialize the current deterministic core state (see `docs/adr/0006` for the on-disk + /// format) to a byte blob the mobile shell can persist however it likes (a file, `SharedPreferences`/ + /// `UserDefaults`-adjacent storage, etc. — this crate has no filesystem opinion). + #[must_use] + pub fn save_state(&self) -> Vec { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .save_state() + } + + /// Restore a state blob previously produced by [`Self::save_state`] against the SAME ROM. + /// + /// # Errors + /// [`MobileError::StateLoad`] on bad magic, an unsupported `FORMAT_VERSION`, or truncated + /// data — see `crates/rustysnes-core/src/scheduler.rs`'s `FORMAT_VERSION` doc for why this is + /// intentionally fail-loud rather than a graceful old-format migration. + // See `load_rom`'s identical comment -- `#[uniffi::export]` needs an owned buffer. + #[allow(clippy::needless_pass_by_value)] + pub fn load_state(&self, blob: Vec) -> Result<(), MobileError> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .load_state(&blob) + .map_err(|e| MobileError::StateLoad(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_lorom() -> Vec { + // A minimal-but-valid LoROM header: name padded with spaces, mode byte 0x20 (LoROM, + // FastROM off), a plausible checksum/complement pair at the fixed LoROM header offset + // (0x7FC0) -- enough for `EmuCore::load_rom`'s header detection to accept it, matching + // the same fixture shape `rustysnes-script`'s own bus-widening test uses. + let mut rom = vec![0u8; 0x1_0000]; + let header = 0x7FC0; + rom[header..header + 21].copy_from_slice(b"RUSTYSNES MOBILE TEST"); + rom[header + 0x15] = 0x20; // LoROM, FastROM off + rom[header + 0x16] = 0x00; // no coprocessor + rom[header + 0x17] = 0x08; // 2 Mbit ROM size + rom[header + 0x18] = 0x00; // no SRAM + rom[header + 0x1A] = 0x01; // licensee + rom[header + 0x1C] = 0x00; + rom[header + 0x1D] = 0x00; + rom[header + 0x1E] = 0xFF; + rom[header + 0x1F] = 0xFF; + rom + } + + #[test] + fn new_core_has_no_rom_loaded() { + let core = MobileCore::new(MobileRegion::Ntsc); + assert!(!core.rom_loaded()); + } + + #[test] + fn load_rom_then_run_frame_produces_a_correctly_sized_framebuffer() { + let core = MobileCore::new(MobileRegion::Ntsc); + core.load_rom(minimal_lorom()).expect("load"); + assert!(core.rom_loaded()); + core.run_frame(); + let size = core.frame_size(); + assert_eq!( + size, + FrameSize { + width: 256, + height: 224 + } + ); + assert_eq!( + core.framebuffer().len(), + (size.width * size.height * 4) as usize + ); + } + + #[test] + fn load_rom_rejects_an_empty_image() { + let core = MobileCore::new(MobileRegion::Ntsc); + let err = core.load_rom(Vec::new()).unwrap_err(); + assert!(matches!(err, MobileError::RomLoad(_))); + } + + #[test] + fn save_state_round_trips_through_load_state() { + let core = MobileCore::new(MobileRegion::Ntsc); + core.load_rom(minimal_lorom()).expect("load"); + core.run_frame(); + let blob = core.save_state(); + assert!(!blob.is_empty()); + core.load_state(blob).expect("round trip"); + } + + #[test] + fn load_state_rejects_garbage() { + let core = MobileCore::new(MobileRegion::Ntsc); + core.load_rom(minimal_lorom()).expect("load"); + let err = core.load_state(vec![0u8; 4]).unwrap_err(); + assert!(matches!(err, MobileError::StateLoad(_))); + } + + #[test] + fn set_pad_and_port_device_do_not_panic_without_a_rom() { + let core = MobileCore::new(MobileRegion::Ntsc); + core.set_pad(0, 0xFFFF); + core.set_port_device(1, MobilePortDevice::Mouse); + core.set_mouse(1, 5, -5, true, false); + core.set_superscope(1, 100, 50, 0); + core.set_multitap_pad(1, 2, 0x00FF); + } + + #[test] + fn drain_audio_returns_interleaved_stereo_samples() { + let core = MobileCore::new(MobileRegion::Ntsc); + core.load_rom(minimal_lorom()).expect("load"); + core.run_frame(); + let audio = core.drain_audio(); + assert_eq!(audio.len() % 2, 0, "interleaved L/R must be an even count"); + } +} diff --git a/docs/adr/0012-mobile-platform-target.md b/docs/adr/0012-mobile-platform-target.md new file mode 100644 index 0000000..8d8f85a --- /dev/null +++ b/docs/adr/0012-mobile-platform-target.md @@ -0,0 +1,85 @@ +# ADR 0012 — Mobile platform target (Android + iOS via a `UniFFI` bridge) + +## Status + +Accepted (`v1.14.0 "Foundry"`). + +## Context + +`to-dos/VERSION-PLAN.md`'s "Post-v1.0 — Reach (deferred)" section recorded, since `v1.0.0`: "any +future mobile/Android target (no appetite assumed by default, unlike RustyNES's own Android +build — don't inherit that scope blindly)." That default has now been explicitly reversed by the +project owner as part of scoping the RustyNES-parity roadmap's `v1.14.0`-`v1.18.0` mobile track. + +Two prerequisites this decision depends on were already in place before this ADR, not something +landing alongside it: + +- `rustysnes_cart::Board: Send` since `v1.0.0` (the same trait bound `emu-thread` already relies + on) — a mobile host driving the emulator from a dedicated render/audio thread needs the same + guarantee `emu-thread` already proved out on desktop. +- Every chip crate (`rustysnes-{cpu,ppu,apu,cart}`) has been `#![no_std]` + `alloc` since before + `v1.0.0`, proven continuously by the `no_std` CI gate against `thumbv7em-none-eabihf`. + +`rustysnes_core::facade::EmuCore` (the same facade `rustysnes-frontend` and `rustysnes-libretro` +already drive the emulator through) is `std`-only (needs `zip` archive extraction for +`.zip`-wrapped ROMs). This is not a blocker: Android's (`aarch64-linux-android`, +`x86_64-linux-android`) and iOS's (`aarch64-apple-ios`, `aarch64-apple-ios-sim`) Rust targets are +`std`-supporting Tier 2/3 targets, not bare-metal — unlike the `thumbv7em-none-eabihf` gate the +chip crates alone are held to. + +RustyNES's own mobile bridge had to absorb nine patch releases (`v2.0.1`-`v2.0.9` "Harbor") +re-porting after its `v2.0.0` scheduler rewrite broke the save-state/movie format — the same class +of risk this project's own `docs/adr/0002` (the fractional-timebase refactor) names. That refactor +was assessed and found **not currently warranted** at `v1.1.0` +(`docs/audit/fractional-timebase-go-no-go-2026-07-11.md`), and five stable minors since then +(`v1.0.0`-`v1.4.0`) plus the entire RustyNES-parity ladder (`v1.5.0`-`v1.13.0`) have shipped with +zero save-state-format churn (`FORMAT_VERSION` last bumped at `v0.9.0`). This is cited here as a +fact informing the mobile track's risk profile, not a new decision made by this ADR. + +## Decision + +- **Bridge technology: `UniFFI`**, generating both Kotlin (Android) and Swift (iOS) bindings from + one Rust source of truth (`crates/rustysnes-mobile`), rather than hand-written JNI + + `swift-bridge`/raw C FFI maintained twice. `UniFFI`'s proc-macro export style + (`#[uniffi::export]`, `uniffi::setup_scaffolding!()`) needs no `.udl` interface-definition file + to hand-maintain in parallel with the Rust source. +- **The bridge wraps `EmuCore`, adds no new emulation logic.** `rustysnes-mobile` is a thin FFI + adapter over the exact same facade the desktop frontend and the libretro core already use — the + same "one emulation core, multiple embeddings" shape this project has followed since `v1.2.0` + relocated `EmuCore` out of the frontend crate specifically to enable this. +- **Mobile UI is a native Compose/SwiftUI shell over a raw `wgpu` surface** (`v1.15.0`/`v1.16.0`), + not an attempt to run `egui` with touch input. `rustysnes-gfx-shaders` (`v1.12.0`) was extracted + specifically so this shell can reuse the exact `BLIT_WGSL`/`CRT_WGSL`/`HQX_WGSL`/`XBRZ_WGSL` + shader strings without depending on `rustysnes-frontend`'s winit/egui/cpal dependency graph. +- **`no_std` CI gate expanded to a per-crate matrix** (`v1.14.0`) — each chip crate now builds + standalone against `thumbv7em-none-eabihf --no-default-features`, not only transitively through + `rustysnes-core`'s own build. This was already implied by each crate's own `#![no_std]` + posture; the gate now actually proves it per-crate instead of only in aggregate. +- **Android-first, iOS honestly scaffolded-but-untested where the toolchain is unavailable.** This + development environment has a real Android SDK/NDK (confirmed: NDK r29, both the + `aarch64-linux-android` and `x86_64-linux-android` Rust targets, `cargo-ndk`) but no macOS/Xcode + toolchain. `v1.14.0`'s + Rust bridge and its generated Kotlin AND Swift bindings are both verified to actually generate + correctly (real `cargo ndk` cross-compiles producing a genuine ARM64 `.so`, real + `uniffi-bindgen`-generated `.kt`/`.swift` source inspected for correct method signatures). The + iOS-specific `rustysnes-ios` crate and SwiftUI shell (`v1.16.0 "Beacon"`) will be written and + will compile-check via `cargo build --target aarch64-apple-ios` where cross-compilation permits, + but the actual Xcode build/link/run step needs the project owner's own Mac to verify — flagged + explicitly at that point, not silently claimed as done. + +## Consequences + +- The mobile track (`v1.14.0`-`v1.18.0`) is now in scope, reversing the `v1.0.0`-era default. + `to-dos/VERSION-PLAN.md`'s "Post-v1.0 — Reach (deferred)" no-mobile-appetite line is corrected + in the same change as this ADR. +- `rustysnes-mobile` becomes a new permanent crate in the workspace graph, depending only on + `rustysnes-core` (default/`std` features) and `rustysnes-cart` — no new dependency of any + existing crate ON `rustysnes-mobile` (strictly a leaf, matching this project's one-directional + dependency-graph rule). +- Real Android on-device/emulator verification is possible in this environment going forward + (an AVD exists, though it needs a working device definition — a `v1.15.0` concern, not this + ADR's). Real iOS verification is not, and is explicitly out of this environment's reach for the + duration of the mobile track unless that changes. +- This does NOT reopen `docs/adr/0002`'s fractional-timebase question — the mobile track's own + risk (a scheduler rewrite breaking save-state/movie compatibility) is judged already retired by + that ADR's `v1.1.0` "not currently warranted" finding, cited here, not re-litigated. diff --git a/docs/mobile-readiness.md b/docs/mobile-readiness.md new file mode 100644 index 0000000..c5153de --- /dev/null +++ b/docs/mobile-readiness.md @@ -0,0 +1,84 @@ +# Mobile readiness + +Tracks the RustyNES-parity roadmap's mobile track (`v1.14.0 "Foundry"` through `v1.18.0 +"Dormant"`) — what exists, what's verified, and what's still needed before a real store +submission. See `docs/adr/0012-mobile-platform-target.md` for the platform-target decision +itself; this doc is the living status page, not the decision record. + +## Architecture + +`crates/rustysnes-mobile` is a thin `UniFFI` bridge over `rustysnes_core::facade::EmuCore` — the +same facade `rustysnes-frontend` and `rustysnes-libretro` already drive the emulator through. It +adds no new emulation logic; every method is a direct, FFI-safe wrapper (see the crate's own +module doc for the full rationale and threading model). + +**Surface (`v1.14.0`):** `MobileCore::new`, `load_rom`/`close_rom`/`rom_loaded`, +`reset`/`power_cycle`, `run_frame`, `framebuffer`/`frame_size`, `drain_audio`, `set_pad`/ +`set_port_device`/`set_mouse`/`set_superscope`/`set_multitap_pad`, `save_state`/`load_state`. +`drain_audio` is **non-destructive** — it returns the current frame's buffered samples, not a +FIFO/pop-style drain; call it exactly once per `run_frame` (which clears and refills the buffer +at its own start), mirroring `EmuCore::audio`'s own contract that every existing consumer +(`rustysnes-frontend`'s synchronous and `emu-thread` render paths alike) already relies on. + +**Deliberately NOT in scope yet** (honest deferral, not silent gaps — every one of these is a +real, separate concern layered on top of `EmuCore` in the desktop build too, not something this +bridge needs to re-invent to reach a playable MVP): + +- HD-pack texture-pack consumption +- Cheats, rewind, run-ahead +- Netplay +- `RetroAchievements` +- Lua/TAS scripting + +**Mobile UI**: a native Compose (Android) / SwiftUI (iOS) shell over a raw `wgpu` surface — not +an attempt to run `egui` with touch input (`docs/adr/0012`'s decision). `rustysnes-gfx-shaders` +(`v1.12.0`) exists specifically so this shell can reuse `BLIT_WGSL`/`CRT_WGSL`/`HQX_WGSL`/ +`XBRZ_WGSL` verbatim without pulling in `rustysnes-frontend`'s winit/egui/cpal dependency graph. + +## Verified so far (`v1.14.0`) + +- `cargo build`/`cargo test -p rustysnes-mobile` on the host: 7 unit tests covering ROM load + (success + empty-image rejection), framebuffer sizing after a real frame, save-state + round-trip, save-state garbage rejection, and every peripheral setter not panicking without a + loaded ROM. +- `uniffi-bindgen generate --library --language kotlin` produces real, correctly + named Kotlin (`loadRom`, `runFrame`, `frameSize`, `drainAudio`, `setPortDevice`, ... — every + method present, correct types, `@Throws` on the fallible ones). +- The same, for `--language swift` — correct `func` signatures, `Data` for byte buffers, `throws` + on the fallible methods. +- A **real cross-compile to `arm64-v8a`** via `cargo ndk -t arm64-v8a build -p rustysnes-mobile` + against this environment's actual NDK (r29), producing a genuine + `ELF 64-bit LSB shared object, ARM aarch64 ... for Android 21, built by NDK r29` — confirmed via + `file`, not just a successful exit code. +- Per-crate `no_std` CI matrix (`rustysnes-{cpu,ppu,apu,cart,core}` each build standalone against + `thumbv7em-none-eabihf --no-default-features`), replacing the prior single aggregate-only job. + +## Not yet verified / explicitly deferred + +- **No real Android app or emulator run yet.** `v1.15.0 "Sideload"` is where the actual Kotlin + Compose shell, JNI host, `SurfaceView`-backed `wgpu` surface, and AAudio sink land. An AVD + exists in this environment (`Pixel_8.avd`) but currently fails to load (`Google pixel_8 no + longer exists as a device` — a stale device-definition mismatch, not an emulator-infrastructure + problem); fixing that is a `v1.15.0` task, not blocking this rung. +- **No iOS build/link/run at all.** This development environment has no macOS/Xcode toolchain. + `v1.16.0 "Beacon"`'s `rustysnes-ios` crate and SwiftUI shell will be written and Rust-side + compile-checked wherever `cargo build --target aarch64-apple-ios` (or the simulator target) + succeeds without needing Xcode itself, but the real build/link/run/on-device or + on-simulator verification needs the project owner's own Mac — this will be flagged explicitly + at that point, not silently claimed as done. +- **No touch UX yet** — the Mouse-mode trackpad, Super Scope drag-reticle, and Multitap + pass-and-play seat switcher are net-new SNES-specific UI with no RustyNES desktop precedent to + port; `v1.15.0`'s own scope. +- **No store-submission readiness assessment yet** — that's the standing "Mobile Phase 6" + go/no-go gate in `to-dos/ROADMAP.md`, deliberately not tied to a fixed version. + +## Risk context (not re-litigated here, only cited) + +RustyNES's own mobile bridge absorbed nine patch releases (`v2.0.1`-`v2.0.9` "Harbor") +re-porting after a scheduler rewrite broke its save-state/movie format. RustySNES carries the +same NAMED risk (`docs/adr/0002`, the fractional-timebase refactor) — already assessed and found +**not currently warranted** at `v1.1.0` +(`docs/audit/fractional-timebase-go-no-go-2026-07-11.md`), with zero save-state-format churn +since (`FORMAT_VERSION` last bumped `v0.9.0`, five stable minors plus the entire +`v1.5.0`-`v1.13.0` ladder shipped since). The mobile track does not need to wait on this; it's a +fact informing risk, not an open decision. diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index ba6459b..d6ab539 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -311,8 +311,10 @@ under `v1.0.0`) and the netplay save-state-cost pre-work. and HD texture packs — the three items originally deferred here — all shipped in `v1.2.0`/ `v1.3.0` above (see `to-dos/VERSION-PLAN.md`'s "Post-v1.0 — Reach"). What's still genuinely open: the SPC7110/PAL/ExLoROM/ST018/S-RTC real-ROM-validation gaps (all ROM-sourcing-blocked, tracked - in `docs/rom-test-corpus.md`) and any future mobile/Android target (no appetite assumed by - default). Movies/scripting/RetroAchievements/rewind-recording on `emu-thread` are reclassified + in `docs/rom-test-corpus.md`). The mobile/Android + iOS target — previously "no appetite + assumed by default" here — is now explicitly in scope as of `v1.14.0 "Foundry"`; see the + RustyNES-parity ladder entry below and `docs/adr/0012-mobile-platform-target.md`. + Movies/scripting/RetroAchievements/rewind-recording on `emu-thread` are reclassified as an intentional, permanent architecture boundary rather than a remaining gap — confirmed by directly reading RustyNES's own mature `emu_thread.rs`, which doesn't port any of these to its thread either. None of the still-open items currently gate a numbered rung — they're an ongoing, @@ -341,9 +343,13 @@ under `v1.0.0`) and the netplay save-state-cost pre-work. **RELEASED 2026-07-12** — `AppTheme::HighContrast`/`Colorblind`; the plan's other two items, a save-state migration fixture and a keyboard-nav audit, were investigated and found to already be a verified-non-issue and a manual-walkthrough task respectively, not code fixes — both - honestly re-scoped rather than force-fit), then a - full mobile track — Android + iOS apps plus dormant monetization scaffolding - (`v1.14.0`-`v1.18.0`) — and a PGO/BOLT pipeline last (`v1.19.0`). Tracked in lockstep against + honestly re-scoped rather than force-fit), then the mobile track's bridge foundations + (`v1.14.0 "Foundry"` — new `rustysnes-mobile` `UniFFI` crate over the same `EmuCore` facade the + desktop frontend uses; verified for real with a genuine `cargo ndk` ARM64 cross-compile and + inspected Kotlin/Swift binding output, not just claimed; the mobile/Android+iOS "no appetite" + default from `v1.0.0` is formally reversed, `docs/adr/0012`) — with + the rest of the mobile track — Android + iOS apps plus dormant monetization scaffolding + (`v1.15.0`-`v1.18.0`) — and a PGO/BOLT pipeline last (`v1.19.0`). Tracked in lockstep against RustyNES's own continuing development via `to-dos/LOCKSTEP-CHECKLIST.md`, not a frozen snapshot target. Full detail in `to-dos/VERSION-PLAN.md`'s "RustyNES-parity ladder" section. - **Flagged by the 2026-07-12 lockstep re-check — no rung assigned yet, maintainer go/no-go diff --git a/to-dos/VERSION-PLAN.md b/to-dos/VERSION-PLAN.md index 94a84a7..dc7afee 100644 --- a/to-dos/VERSION-PLAN.md +++ b/to-dos/VERSION-PLAN.md @@ -868,8 +868,9 @@ gate; README/CHANGELOG/`docs/`/`docs/STATUS.md` fully in sync. - **Libretro core**, the **CRT/HQx shader/filter pipeline**, and **HD texture packs** landed in `v1.2.0`/`v1.3.0` above. Still deferred: the **fractional-timebase MAJOR refactor** (`docs/adr/0002`, only if hard residuals from the accuracy-debt cluster above actually warrant - it), and any future **mobile/Android** target (no appetite assumed by default, unlike - RustyNES's own Android build — don't inherit that scope blindly). + it). The **mobile/Android + iOS target** — previously "no appetite assumed by default" here — + is now explicitly IN SCOPE as of `v1.14.0 "Foundry"`; see `docs/adr/0012-mobile-platform- + target.md` for the reversal decision and `docs/mobile-readiness.md` for the living status page. ## Standards adopted for every release from v0.1.0 onward @@ -1161,17 +1162,40 @@ into a hollow "audit passed" claim. See `docs/frontend.md` for the full explanat rationale; stands up `docs/mobile-readiness.md`; a new ADR records the mobile-platform-target decision, citing that `docs/adr/0002`'s gate is already closed favorably. -### `v1.14.0 "Foundry"` → `v1.18.0 "Dormant"` — the mobile track - -New crates `rustysnes-mobile` (UniFFI bridge — `Board: Send` since `v1.0.0`, the chip-stack crates -already `#![no_std]`+alloc), `rustysnes-android` (JNI/NDK, Kotlin Compose shell, net-new -Mouse/Super-Scope/Multitap touch UX with no RustyNES precedent), `rustysnes-ios` (Metal via wgpu, -SwiftUI shell, TestFlight), then a hardening rung (mlua `send`-feature migration if scripting ships -on mobile, direct-IP/LAN netplay, per-platform parity checklist), then `rustysnes-monetization` -(dormant RevenueCat/AppLovin-style scaffold, never a dependency of the deterministic core, policy -shape only — no committed pricing). A store-launch decision (Play + App Store submission, -monetization activation) is an explicit maintainer go/no-go against `docs/mobile-readiness.md`, not -a numbered rung — mirroring RustyNES's own still-pending, twice-deferred launch. +### `v1.14.0 "Foundry"` — Mobile Phase 1: bridge foundations + +Implemented (this PR; the version bump + "RELEASED" marker land in the separate +`chore(release)` closeout PR, matching every prior rung's two-PR pattern): new crate +`rustysnes-mobile`, a `UniFFI` bridge over `rustysnes_core::facade::EmuCore` +(the same `std`-only facade the desktop frontend and `rustysnes-libretro` already drive the +emulator through — `Board: Send` since `v1.0.0` and the chip-stack crates' `#![no_std]`+`alloc` +posture were both already proven, not new work here). MVP surface: ROM load/close, `run_frame`, +the peripheral setters (Gamepad/Mouse/Super Scope/Multitap), framebuffer + per-frame audio +access, save/load state, reset/power-cycle — 7 host-side unit tests. Verified for real, not just +claimed: a genuine `cargo ndk` cross-compile to `arm64-v8a` produced an actual ARM64 `.so` +(confirmed via `file`), and `uniffi-bindgen` generated real, correctly-shaped Kotlin AND Swift +bindings (inspected for +correct method names/types/`throws`/`@Throws`) from the compiled library. The per-crate `no_std` +CI matrix (`rustysnes-{cpu,ppu,apu,cart,core}` each building standalone, not only transitively +through `rustysnes-core`) also landed here. New `docs/adr/0012-mobile-platform-target.md` + +`docs/mobile-readiness.md` (the living status page); this document's own "Post-v1.0 — Reach" +no-mobile-appetite line is reversed above. + +**Honestly scoped**: this development environment has a real Android SDK/NDK but no macOS/Xcode +toolchain, so Android-side work (`v1.15.0`) can be genuinely built/tested here going forward, +while iOS-side work (`v1.16.0`) will be written and Rust-side compile-checked but needs the +project owner's own Mac for a real Xcode build/link/run — see `docs/mobile-readiness.md`. + +### `v1.15.0 "Sideload"` → `v1.18.0 "Dormant"` — the rest of the mobile track + +`rustysnes-android` (JNI/NDK, Kotlin Compose shell, net-new Mouse/Super-Scope/Multitap touch UX +with no RustyNES precedent), `rustysnes-ios` (Metal via wgpu, SwiftUI shell, TestFlight), then a +hardening rung (mlua `send`-feature migration if scripting ships on mobile, direct-IP/LAN +netplay, per-platform parity checklist), then `rustysnes-monetization` (dormant +RevenueCat/AppLovin-style scaffold, never a dependency of the deterministic core, policy shape +only — no committed pricing). A store-launch decision (Play + App Store submission, monetization +activation) is an explicit maintainer go/no-go against `docs/mobile-readiness.md`, not a numbered +rung — mirroring RustyNES's own still-pending, twice-deferred launch. ### `v1.19.0 "Afterburner"` — PGO/BOLT pipeline