diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e4a8d66..cdb86c3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.9] - 2026-06-26 - "Workshop" (iOS creator / power tools + readiness gate) + +The final iOS TestFlight release before the v2.0.0 "Timebase" core rewrite — the +analogue of Android v1.8.9. It brings the desktop creator/power-tools to touch and +runs a full pre-freeze readiness pass (verification, security audit, gap analysis, a +completeness-critic sweep). Everything is **additive / off-by-default**: the bridge +gains only new forwarding fns over **existing** `rustynes-core` APIs (no signature or +behaviour change), the deterministic core / chip crates are untouched, and the +shipped / native / `no_std` / wasm core stays **byte-identical** — **AccuracyCoin +holds 100% (139/139)**. Interim TestFlight. + +### Added + +- **Cheats** — a Game Genie editor (persistent live codes via the core engine, + applied on every PRG read) plus one-shot raw-RAM peek / poke of the `$0000–$1FFF` + work-RAM region, behind a new Cheats sheet. +- **Read-only debugger inspector** (FOSS / TestFlight build only, gated off the + App-Store channel per ADR 0027) — live CPU registers, a disassembly window around + the PC, a hex memory view, and single-frame step, over new observational bridge + fns (`debug_cpu_state` / `debug_read_memory` / `debug_disassemble`). +- **TAStudio piano-roll** — a touch frame-input table with deterministic scripted + playback and record-to-`.rnm` from power-on. +- **Foreign movie import** — `.fm2` / `.bk2` / `.fcm` / `.fmv` / `.vmv` transcoded to + the native `.rnm` format via new bridge importers (bounds-checked: 16 MiB + per-ZIP-member cap, graceful errors on malformed input — never panics). +- **Audio depth** — a host-side DSP stage in `rustynes-ios` (5-band EQ, per-channel + panning, Schroeder reverb, crossfeed), fully bypassable (disabled / flat / centered + is bit-for-bit the v1.9.8 passthrough; the cpal callback drains one sample per + output frame regardless of state). +- **Symbol maps** — host-side `.sym` / `.mlb` / `.nl` label loading feeding the + debugger view. + +### Changed + +- **`rustynes-mobile` bridge** gains additive forwarding fns (cheats, foreign-movie + importers, observational debugger reads) — the first iOS release to extend the + shared bridge surface; all additive, so the Android build is unaffected and the + core stays byte-identical. + +### Fixed + +- File imports (ROM, palette, symbol map) and foreign-movie transcoding now run off + the main thread (`Task.detached`), so a large user-picked file no longer hangs the + UI; `poke_ram` clamps the address to the work-RAM mirror bridge-side; missed + EN/ES catalog strings and VoiceOver labels across the new creator-tool views added. + +### Notes + +- **Mobile ROM loading is iNES / NES 2.0 only.** The import picker no longer + advertises `.fds` / `.nsf` (the shared bridge has no FDS / NSF load path) — FDS + disk + NSF playback on mobile are a post-v2.0.0 carryover, alongside the 20-band EQ, + `.dbg` ca65/cc65 source maps, and a cheats database. + ## [1.9.8] - 2026-06-26 - "Horizon" (iOS store-readiness) The iOS store-readiness release — the analogue of Android v1.8.8 "Atlas". A broad diff --git a/Cargo.lock b/Cargo.lock index 5d88e880f..f7a4b077e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4083,7 +4083,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "1.9.8" +version = "1.9.9" dependencies = [ "android-activity", "android_logger", @@ -4101,7 +4101,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4114,7 +4114,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "1.9.8" +version = "1.9.9" dependencies = [ "cc", "ureq", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4139,7 +4139,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4150,7 +4150,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "1.9.8" +version = "1.9.9" dependencies = [ "anstyle", "arboard", @@ -4202,11 +4202,11 @@ dependencies = [ [[package]] name = "rustynes-gfx-shaders" -version = "1.9.8" +version = "1.9.9" [[package]] name = "rustynes-hdpack" -version = "1.9.8" +version = "1.9.9" dependencies = [ "lewton", "png", @@ -4217,7 +4217,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bytemuck", "cpal", @@ -4231,7 +4231,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4243,7 +4243,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "1.9.8" +version = "1.9.9" dependencies = [ "rustynes-core", "rustynes-hdpack", @@ -4258,14 +4258,14 @@ dependencies = [ [[package]] name = "rustynes-monetization" -version = "1.9.8" +version = "1.9.9" dependencies = [ "uniffi", ] [[package]] name = "rustynes-netplay" -version = "1.9.8" +version = "1.9.9" dependencies = [ "futures-util", "js-sys", @@ -4281,7 +4281,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "1.9.8" +version = "1.9.9" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4292,14 +4292,14 @@ dependencies = [ [[package]] name = "rustynes-ra" -version = "1.9.8" +version = "1.9.9" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "1.9.8" +version = "1.9.9" dependencies = [ "mlua", "piccolo", @@ -4310,7 +4310,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "1.9.8" +version = "1.9.9" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index 609677423..a8971f4ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "1.9.8" +version = "1.9.9" edition = "2024" rust-version = "1.96" license = "MIT OR Apache-2.0" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fd3ff1e23..0627664ba 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,8 +39,8 @@ android { applicationId = "com.doublegate.rustynes" minSdk = 26 // AAudio floor. targetSdk = 36 // Play mandate (Android 16) from 2026-08-31. - versionCode = 10908 // 1.9.8 - versionName = "1.9.8" + versionCode = 10909 // 1.9.9 + versionName = "1.9.9" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. // PLAY_BUILD gates the freemium (demo timer + persistence locks + Billing). diff --git a/crates/rustynes-ios/include/rustynes_ios.h b/crates/rustynes-ios/include/rustynes_ios.h index 7c0c4e8c5..b54558edd 100644 --- a/crates/rustynes-ios/include/rustynes_ios.h +++ b/crates/rustynes-ios/include/rustynes_ios.h @@ -72,6 +72,16 @@ void rustynes_ios_audio_push(RustyNesAudioSink *handle, const float *samples, si /* The negotiated device sample rate (request it from NesController::new). 0 if NULL. */ uint32_t rustynes_ios_audio_sample_rate(RustyNesAudioSink *handle); +/* Publish the live audio-depth DSP config (v1.9.9): EQ / pan / reverb / crossfeed, + * applied host-side after drain_audio. `eq` is up to 5 band gains (dB), `pan` up to + * 6 per-channel positions (-1..=1); extra entries ignored, missing ones neutral. + * `enabled == 0` (or a flat / centered config) is a bit-exact passthrough. The + * `eq` / `pan` pointers may be NULL (treated as neutral). No-op on a NULL handle. */ +void rustynes_ios_audio_set_depth(RustyNesAudioSink *handle, uint8_t enabled, + const float *eq, size_t eq_len, + const float *pan, size_t pan_len, + float reverb_mix, float reverb_room, float crossfeed); + /* Pause / resume the output stream (scene background / audio interruption). */ void rustynes_ios_audio_pause(RustyNesAudioSink *handle); void rustynes_ios_audio_resume(RustyNesAudioSink *handle); diff --git a/crates/rustynes-ios/src/audio.rs b/crates/rustynes-ios/src/audio.rs index 804237cbb..6a96b7c4c 100644 --- a/crates/rustynes-ios/src/audio.rs +++ b/crates/rustynes-ios/src/audio.rs @@ -24,6 +24,8 @@ use std::sync::Arc; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::audio_dsp::{AudioDepth, DepthConfig, DepthParams}; + /// A single-producer / single-consumer lock-free ring of mono `f32` samples. /// /// The producer ([`AudioSink::push`], called on the emu thread) only advances @@ -122,6 +124,12 @@ pub struct AudioSink { ring: Arc, stream: cpal::Stream, sample_rate: u32, + /// The live audio-depth (EQ / pan / reverb / crossfeed) configuration mailbox + /// (v1.9.9). The Swift Settings UI publishes a [`DepthConfig`] via + /// [`AudioSink::set_depth`]; the real-time callback snapshots it once per + /// buffer. Default / disabled = bit-exact passthrough, so the determinism + /// contract is untouched. + depth_params: Arc, } impl AudioSink { @@ -148,16 +156,46 @@ impl AudioSink { let ring = Arc::new(Ring::new((sample_rate as usize) / 4)); let ring_cb = Arc::clone(&ring); + // The audio-depth DSP (v1.9.9): the callback owns a stateful processor + // (EQ biquad history + reverb delay lines, allocated once) and reads the + // live config from the shared `DepthParams` mailbox once per buffer. + let depth_params = Arc::new(DepthParams::new()); + let depth_params_cb = Arc::clone(&depth_params); + let mut depth = AudioDepth::new(sample_rate); + let stream = device .build_output_stream( config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - // Expand the mono ring to the device's interleaved channels: - // one queued sample fans out to every channel of a frame. + // Mirror the live config in once per buffer (cheap; re-voices + // only on an actual change), then check bypass once. + depth.apply(&depth_params_cb.snapshot()); + let bypass = depth.is_bypass(); for frame in data.chunks_mut(channels) { - let s = ring_cb.pop(); - for ch in frame.iter_mut() { - *ch = s; + // Exactly one queued mono sample is consumed per output + // frame regardless of bypass, so toggling the DSP never + // changes the drain rate. + let mono = ring_cb.pop(); + if bypass { + // Fan the mono value out to every channel (the + // byte-identical pre-v1.9.9 behaviour). + for ch in frame.iter_mut() { + *ch = mono; + } + } else { + let (l, r) = depth.process(mono); + match frame { + [] => {} + [c0] => *c0 = 0.5 * (l + r), + [c0, c1, rest @ ..] => { + *c0 = l; + *c1 = r; + // Any surround channels get the left image. + for ch in rest { + *ch = l; + } + } + } } } }, @@ -171,6 +209,7 @@ impl AudioSink { ring, stream, sample_rate, + depth_params, }) } @@ -195,4 +234,11 @@ impl AudioSink { pub fn sample_rate(&self) -> u32 { self.sample_rate } + + /// Publish a new audio-depth (EQ / pan / reverb / crossfeed) configuration + /// (v1.9.9). Lock-free: the real-time callback picks it up on its next buffer. + /// A default / disabled config is a bit-exact passthrough. + pub fn set_depth(&self, cfg: &DepthConfig) { + self.depth_params.store(cfg); + } } diff --git a/crates/rustynes-ios/src/audio_dsp.rs b/crates/rustynes-ios/src/audio_dsp.rs new file mode 100644 index 000000000..0cfdb97a7 --- /dev/null +++ b/crates/rustynes-ios/src/audio_dsp.rs @@ -0,0 +1,652 @@ +//! iOS host audio-depth DSP (v1.9.9 "Workshop"). +//! +//! A **host, output-only** stereo-enrichment stage applied in the CoreAudio +//! callback *after* the mono APU master is drained from the core +//! (`NesController.drain_audio`) — exactly like the desktop frontend's +//! `audio_dsp` / `eq` stages. It never touches the deterministic core synthesis, +//! so the determinism contract (save-state / TAS / netplay / the `AccuracyCoin` +//! audio oracle) is preserved. +//! +//! Four stages, in order: a 5-band peaking **equalizer**, a mono -> stereo +//! **pan** image, a small Schroeder **reverb**, and a headphone **crossfeed**. +//! Each defaults to a true bypass: +//! +//! - every EQ band at 0 dB -> identity; +//! - pan center (0.0) -> equal L/R, the mono value duplicated bit-for-bit; +//! - reverb mix 0.0 -> dry passthrough; +//! - crossfeed 0.0 -> channels untouched. +//! +//! With everything at its default (or the whole stage disabled) the `AudioDepth` +//! stage is in **bypass**: its `process` returns the mono value duplicated +//! bit-for-bit on both channels, so the output is byte-identical to a build +//! without this module. The DSP math is ported from the desktop frontend's +//! `audio_dsp.rs` (pan / reverb / crossfeed) and `eq.rs` (RBJ peaking biquads). +//! +//! This module is host-safe (pure `f32` math, no Metal / CoreAudio / cpal +//! dependency), so it compiles and is unit-tested on the workspace host build; +//! `audio.rs` (iOS-only) wires it into the real-time sink and `ffi.rs` exposes +//! the live config setter to Swift. + +// Audio DSP: the textbook biquad/reverb/pan forms read best directly; the +// FMA-vs-separate-ops rounding difference is inaudible and the buffer-length +// casts (delay-line lengths from a positive ms x rate) are exact and positive +// for any real device period. +#![allow( + clippy::suboptimal_flops, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] + +use core::f32::consts::PI; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +/// Number of equalizer bands (the classic 5-band voicing). +pub const EQ_BAND_COUNT: usize = 5; + +/// Number of pan positions (one per APU mixer channel). +/// +/// The slots are Pulse1, Pulse2, Triangle, Noise, DMC, expansion. The active +/// stage applies the mean as a single master image over the pre-mixed mono +/// master (the core hands the host a single mono stream), so the per-channel +/// surface is forward-compatible: an all-center array is bit-exact identity. +pub const PAN_COUNT: usize = 6; + +/// Center frequencies (Hz) of the five fixed EQ bands. +const BAND_FREQS: [f32; EQ_BAND_COUNT] = [60.0, 240.0, 1_000.0, 3_800.0, 12_000.0]; + +/// Per-band Q (bandwidth) — moderate so adjacent bands overlap smoothly. +const BAND_Q: f32 = 0.9; + +/// `sqrt(2)` — scales the equal-power center-pan gain back to unity so a center +/// pan reproduces the mono value bit-for-bit. +const SQRT2: f32 = core::f32::consts::SQRT_2; + +/// A plain, `Copy` snapshot of the audio-depth configuration. The neutral +/// default ([`DepthConfig::BYPASS`]) is a true bypass on every stage. +#[derive(Debug, Clone, Copy)] +pub struct DepthConfig { + /// Master enable. When `false` the whole stage is bypassed regardless of the + /// other fields (the mono value is duplicated bit-for-bit). + pub enabled: bool, + /// Per-band EQ gains in dB (`-12.0..=12.0`); all-zero = flat = bypass. + pub eq_db: [f32; EQ_BAND_COUNT], + /// Per-APU-channel pan in `-1.0..=1.0` (`-1` hard left, `0` center, `+1` + /// hard right). The active stage uses the mean; all-center = identity. + pub pan: [f32; PAN_COUNT], + /// Reverb wet mix `0.0..=1.0` (0 = dry / bypass). + pub reverb_mix: f32, + /// Reverb room size `0.0..=1.0` (decay time). + pub reverb_room: f32, + /// Headphone crossfeed amount `0.0..=1.0` (0 = bypass). + pub crossfeed: f32, +} + +impl DepthConfig { + /// The all-bypass configuration (disabled, flat EQ, center pan, no reverb / + /// crossfeed). Reverb room neutral default is `0.5`. + pub const BYPASS: Self = Self { + enabled: false, + eq_db: [0.0; EQ_BAND_COUNT], + pan: [0.0; PAN_COUNT], + reverb_mix: 0.0, + reverb_room: 0.5, + crossfeed: 0.0, + }; +} + +impl Default for DepthConfig { + fn default() -> Self { + Self::BYPASS + } +} + +/// A lock-free, atomically-shared mailbox for the live [`DepthConfig`]. +/// +/// The FFI thread writes via [`DepthParams::store`]; the real-time audio +/// callback reads via [`DepthParams::snapshot`] once per buffer and applies the +/// result. Floats are stored as their bit pattern in `AtomicU32`. There is no +/// cross-field tearing concern: the callback re-derives its filter state from +/// the snapshot, and a one-buffer-stale field only delays a slider by one +/// callback (inaudible). +#[derive(Debug)] +pub struct DepthParams { + enabled: AtomicBool, + eq_db: [AtomicU32; EQ_BAND_COUNT], + pan: [AtomicU32; PAN_COUNT], + reverb_mix: AtomicU32, + reverb_room: AtomicU32, + crossfeed: AtomicU32, +} + +impl Default for DepthParams { + fn default() -> Self { + Self::new() + } +} + +impl DepthParams { + /// A new mailbox holding the all-bypass configuration. + #[must_use] + pub fn new() -> Self { + let cfg = DepthConfig::BYPASS; + Self { + enabled: AtomicBool::new(cfg.enabled), + eq_db: core::array::from_fn(|i| AtomicU32::new(cfg.eq_db[i].to_bits())), + pan: core::array::from_fn(|i| AtomicU32::new(cfg.pan[i].to_bits())), + reverb_mix: AtomicU32::new(cfg.reverb_mix.to_bits()), + reverb_room: AtomicU32::new(cfg.reverb_room.to_bits()), + crossfeed: AtomicU32::new(cfg.crossfeed.to_bits()), + } + } + + /// Publish a new configuration (called off the audio thread). + pub fn store(&self, cfg: &DepthConfig) { + self.enabled.store(cfg.enabled, Ordering::Relaxed); + for (slot, &v) in self.eq_db.iter().zip(cfg.eq_db.iter()) { + slot.store(v.to_bits(), Ordering::Relaxed); + } + for (slot, &v) in self.pan.iter().zip(cfg.pan.iter()) { + slot.store(v.to_bits(), Ordering::Relaxed); + } + self.reverb_mix + .store(cfg.reverb_mix.to_bits(), Ordering::Relaxed); + self.reverb_room + .store(cfg.reverb_room.to_bits(), Ordering::Relaxed); + self.crossfeed + .store(cfg.crossfeed.to_bits(), Ordering::Relaxed); + } + + /// Read the current configuration (called once per audio callback buffer). + #[must_use] + pub fn snapshot(&self) -> DepthConfig { + DepthConfig { + enabled: self.enabled.load(Ordering::Relaxed), + eq_db: core::array::from_fn(|i| f32::from_bits(self.eq_db[i].load(Ordering::Relaxed))), + pan: core::array::from_fn(|i| f32::from_bits(self.pan[i].load(Ordering::Relaxed))), + reverb_mix: f32::from_bits(self.reverb_mix.load(Ordering::Relaxed)), + reverb_room: f32::from_bits(self.reverb_room.load(Ordering::Relaxed)), + crossfeed: f32::from_bits(self.crossfeed.load(Ordering::Relaxed)), + } + } +} + +/// A single Direct-Form-I RBJ peaking biquad (one EQ band). +#[derive(Clone, Copy, Default)] +struct Biquad { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + x1: f32, + x2: f32, + y1: f32, + y2: f32, +} + +impl Biquad { + /// RBJ peaking-EQ coefficients for `freq` (Hz) at `gain_db` and + /// `sample_rate`. Falls back to identity for a band at / above Nyquist or a + /// non-finite sample rate (so a corrupt device rate cannot inject NaNs). + fn peaking(freq: f32, gain_db: f32, q: f32, sample_rate: f32) -> Self { + if !sample_rate.is_finite() || freq >= sample_rate * 0.5 { + return Self { + b0: 1.0, + ..Self::default() + }; + } + let a = 10.0_f32.powf(gain_db / 40.0); + let w0 = 2.0 * PI * (freq / sample_rate); + let (sin_w0, cos_w0) = (w0.sin(), w0.cos()); + let alpha = sin_w0 / (2.0 * q); + + let b0 = 1.0 + alpha * a; + let b1 = -2.0 * cos_w0; + let b2 = 1.0 - alpha * a; + let a0 = 1.0 + alpha / a; + let a1 = -2.0 * cos_w0; + let a2 = 1.0 - alpha / a; + + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } + } + + #[inline] + fn process(&mut self, x0: f32) -> f32 { + let y0 = self.b0 * x0 + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 + - self.a2 * self.y2; + self.x2 = self.x1; + self.x1 = x0; + self.y2 = self.y1; + self.y1 = y0; + y0 + } +} + +/// A simple comb filter (one delay line + feedback) — a Schroeder reverb tap. +struct Comb { + buf: Vec, + idx: usize, + feedback: f32, +} + +impl Comb { + fn new(len: usize, feedback: f32) -> Self { + Self { + buf: vec![0.0; len.max(1)], + idx: 0, + feedback, + } + } + + #[inline] + const fn set_feedback(&mut self, feedback: f32) { + self.feedback = feedback; + } + + #[inline] + fn process(&mut self, x: f32) -> f32 { + let y = self.buf[self.idx]; + self.buf[self.idx] = x + y * self.feedback; + self.idx += 1; + if self.idx >= self.buf.len() { + self.idx = 0; + } + y + } +} + +/// A Schroeder all-pass filter (smears the comb output's metallic ring). +struct AllPass { + buf: Vec, + idx: usize, + gain: f32, +} + +impl AllPass { + fn new(len: usize, gain: f32) -> Self { + Self { + buf: vec![0.0; len.max(1)], + idx: 0, + gain, + } + } + + #[inline] + fn process(&mut self, x: f32) -> f32 { + let v_delayed = self.buf[self.idx]; + let v = x + self.gain * v_delayed; + let y = -self.gain * v + v_delayed; + self.buf[self.idx] = v; + self.idx += 1; + if self.idx >= self.buf.len() { + self.idx = 0; + } + y + } +} + +/// A classic 4-comb + 2-allpass Schroeder reverb over a mono send. +struct Reverb { + combs: Vec, + allpasses: Vec, +} + +impl Reverb { + fn new(sample_rate: u32, room: f32) -> Self { + let sr = sample_rate.max(1) as f32; + let comb_ms = [29.7, 37.1, 41.1, 43.7]; + let allpass_ms = [5.0, 1.7]; + let feedback = Self::room_feedback(room); + let combs = comb_ms + .iter() + .map(|&ms| Comb::new(((ms / 1000.0) * sr) as usize, feedback)) + .collect(); + let allpasses = allpass_ms + .iter() + .map(|&ms| AllPass::new(((ms / 1000.0) * sr) as usize, 0.5)) + .collect(); + Self { combs, allpasses } + } + + #[inline] + fn room_feedback(room: f32) -> f32 { + 0.7 + 0.28 * room.clamp(0.0, 1.0) + } + + /// Re-voice for a new room size in place — only the comb feedback changes; + /// the delay-line lengths are fixed, so no allocation occurs (real-time-safe). + #[inline] + fn set_room(&mut self, room: f32) { + let feedback = Self::room_feedback(room); + for c in &mut self.combs { + c.set_feedback(feedback); + } + } + + #[inline] + fn process(&mut self, x: f32) -> f32 { + let mut acc = 0.0; + for c in &mut self.combs { + acc += c.process(x); + } + acc /= self.combs.len().max(1) as f32; + for a in &mut self.allpasses { + acc = a.process(acc); + } + acc + } +} + +/// The full host audio-depth processor: 5-band EQ -> pan -> reverb -> crossfeed. +/// +/// The reverb (and its comb / all-pass delay lines) is allocated **once** in +/// [`AudioDepth::new`]; nothing on the real-time `process` path allocates (a +/// room-size change only re-voices the fixed combs in place). [`AudioDepth::apply`] +/// mirrors a live [`DepthConfig`] in once per callback. When bypassed, `process` +/// returns the mono value duplicated bit-for-bit on both channels. +pub struct AudioDepth { + enabled: bool, + eq: [Biquad; EQ_BAND_COUNT], + eq_bypass: bool, + built_eq_db: [f32; EQ_BAND_COUNT], + sample_rate: u32, + pan: f32, + reverb_mix: f32, + reverb_room: f32, + crossfeed: f32, + reverb: Reverb, + built_room: f32, +} + +impl AudioDepth { + /// A new all-bypass processor for `sample_rate`. Allocates the reverb delay + /// lines once here so the real-time `process` path never allocates. + #[must_use] + pub fn new(sample_rate: u32) -> Self { + let cfg = DepthConfig::BYPASS; + let sr = sample_rate.max(1); + Self { + enabled: cfg.enabled, + eq: Self::build_eq(cfg.eq_db, sr), + eq_bypass: true, + built_eq_db: cfg.eq_db, + sample_rate: sr, + pan: 0.0, + reverb_mix: cfg.reverb_mix, + reverb_room: cfg.reverb_room, + crossfeed: cfg.crossfeed, + reverb: Reverb::new(sr, cfg.reverb_room), + built_room: cfg.reverb_room, + } + } + + fn build_eq(gains_db: [f32; EQ_BAND_COUNT], sample_rate: u32) -> [Biquad; EQ_BAND_COUNT] { + let sr = sample_rate as f32; + core::array::from_fn(|i| { + Biquad::peaking(BAND_FREQS[i], nan_to_zero(gains_db[i]), BAND_Q, sr) + }) + } + + /// Mirror a live configuration in. Re-voices the EQ only when a band gain + /// actually changes (resetting the biquad history), and the reverb only when + /// the room size changes (feedback only, no realloc). NaN / Inf config values + /// are mapped to their neutral defaults before clamping, so a corrupt config + /// can never poison the DSP state. + pub fn apply(&mut self, cfg: &DepthConfig) { + self.enabled = cfg.enabled; + + let eq_db: [f32; EQ_BAND_COUNT] = + core::array::from_fn(|i| nan_to_zero(cfg.eq_db[i]).clamp(-12.0, 12.0)); + // Exact comparison is intentional: `built_eq_db` is the cache key set + // from the same source values, so a bit-difference is a genuine change. + #[allow(clippy::float_cmp)] + let eq_changed = eq_db != self.built_eq_db; + if eq_changed { + self.eq = Self::build_eq(eq_db, self.sample_rate); + self.built_eq_db = eq_db; + } + self.eq_bypass = eq_db.iter().all(|&g| g.abs() < f32::EPSILON); + + // Master pan is the mean of the per-channel pans (all-center => center). + let mean = cfg + .pan + .iter() + .map(|&p| nan_to_zero(p).clamp(-1.0, 1.0)) + .sum::() + / PAN_COUNT as f32; + self.pan = mean; + self.reverb_mix = nan_to_zero(cfg.reverb_mix).clamp(0.0, 1.0); + self.reverb_room = nan_to_default(cfg.reverb_room, 0.5).clamp(0.0, 1.0); + self.crossfeed = nan_to_zero(cfg.crossfeed).clamp(0.0, 1.0); + } + + /// `true` when the whole stage is at bypass (disabled, or flat EQ + center + /// pan + no reverb + no crossfeed), so the caller can skip the work and emit + /// the mono value duplicated bit-for-bit. + #[must_use] + pub fn is_bypass(&self) -> bool { + !self.enabled + || (self.eq_bypass + && self.pan == 0.0 + && self.reverb_mix == 0.0 + && self.crossfeed == 0.0) + } + + /// Process one mono frame into a stereo `(left, right)` pair. Returns the + /// mono value duplicated when [`Self::is_bypass`] (bit-exact passthrough). + #[inline] + pub fn process(&mut self, mono: f32) -> (f32, f32) { + if self.is_bypass() { + return (mono, mono); + } + + // EQ (mono, cascaded peaking biquads) before the stereo image. + let mut m = mono; + if !self.eq_bypass { + for band in &mut self.eq { + m = band.process(m); + } + } + + // Pan: constant-power, scaled so center == unity (mono duplicated). + let (lg, rg) = pan_gains(self.pan); + let mut l = m * lg * SQRT2; + let mut r = m * rg * SQRT2; + + // Reverb (mono send summed equally into both channels). + if self.reverb_mix > 0.0 { + #[allow(clippy::float_cmp)] + let stale = self.built_room != self.reverb_room; + if stale { + self.reverb.set_room(self.reverb_room); + self.built_room = self.reverb_room; + } + let wet = self.reverb.process(m); + let dry = 1.0 - self.reverb_mix; + l = l * dry + wet * self.reverb_mix; + r = r * dry + wet * self.reverb_mix; + } + + // Crossfeed: blend a fraction of each channel into the other. + if self.crossfeed > 0.0 { + let cf = self.crossfeed * 0.5; + let (nl, nr) = (l * (1.0 - cf) + r * cf, r * (1.0 - cf) + l * cf); + l = nl; + r = nr; + } + (l, r) + } +} + +/// Equal-power pan gains for a mono sample at pan position `p` in `-1.0..=1.0`. +/// At center this returns `(SQRT_1_2, SQRT_1_2)`; the caller scales by `SQRT_2` +/// so center is unity per channel. +#[inline] +fn pan_gains(p: f32) -> (f32, f32) { + let p = p.clamp(-1.0, 1.0); + let theta = (p + 1.0) * 0.25 * PI; + (theta.cos(), theta.sin()) +} + +/// Map a non-finite input (NaN or +/-Inf) to `default`, otherwise pass through. +#[inline] +const fn nan_to_default(x: f32, default: f32) -> f32 { + if x.is_finite() { x } else { default } +} + +/// Non-finite -> `0.0` (the neutral value for EQ gain / pan / mix / crossfeed). +#[inline] +const fn nan_to_zero(x: f32) -> f32 { + nan_to_default(x, 0.0) +} + +#[cfg(test)] +#[allow(clippy::float_cmp)] +mod tests { + use super::*; + + #[test] + fn default_is_bypass_and_duplicates_mono() { + let mut d = AudioDepth::new(48_000); + assert!(d.is_bypass()); + for &v in &[0.0f32, 0.1, -0.5, 1.0, -1.0, 0.333] { + let (l, r) = d.process(v); + assert_eq!(l.to_bits(), v.to_bits()); + assert_eq!(r.to_bits(), v.to_bits()); + } + } + + #[test] + fn enabled_but_neutral_config_is_bypass() { + let mut d = AudioDepth::new(44_100); + d.apply(&DepthConfig { + enabled: true, + ..DepthConfig::BYPASS + }); + assert!(d.is_bypass()); + let (l, r) = d.process(0.42); + assert_eq!(l.to_bits(), 0.42f32.to_bits()); + assert_eq!(r.to_bits(), 0.42f32.to_bits()); + } + + #[test] + fn disabled_overrides_active_stages() { + let mut d = AudioDepth::new(48_000); + // Active settings, but disabled -> still a bit-exact passthrough. + d.apply(&DepthConfig { + enabled: false, + pan: [-1.0; PAN_COUNT], + reverb_mix: 0.5, + crossfeed: 0.5, + eq_db: [6.0; EQ_BAND_COUNT], + ..DepthConfig::BYPASS + }); + assert!(d.is_bypass()); + let (l, r) = d.process(0.5); + assert_eq!(l.to_bits(), 0.5f32.to_bits()); + assert_eq!(r.to_bits(), 0.5f32.to_bits()); + } + + #[test] + fn hard_left_pan_silences_right() { + let mut d = AudioDepth::new(48_000); + d.apply(&DepthConfig { + enabled: true, + pan: [-1.0; PAN_COUNT], + ..DepthConfig::BYPASS + }); + assert!(!d.is_bypass()); + let (l, r) = d.process(0.5); + assert!(l > 0.5, "left should carry the energy: {l}"); + assert!(r.abs() < 1e-4, "right should be ~silent: {r}"); + } + + #[test] + fn eq_boost_makes_stage_active_and_stays_bounded() { + let mut d = AudioDepth::new(48_000); + d.apply(&DepthConfig { + enabled: true, + eq_db: [6.0, 0.0, -6.0, 0.0, 3.0], + ..DepthConfig::BYPASS + }); + assert!(!d.is_bypass()); + let mut peak = 0.0f32; + for i in 0..4096 { + let x = (i as f32 * 0.05).sin() * 0.5; + let (l, r) = d.process(x); + assert!(l.is_finite() && r.is_finite()); + peak = peak.max(l.abs()).max(r.abs()); + } + assert!(peak < 8.0, "EQ output must stay bounded: {peak}"); + } + + #[test] + fn reverb_stays_finite_and_bounded() { + let mut d = AudioDepth::new(48_000); + d.apply(&DepthConfig { + enabled: true, + reverb_mix: 0.4, + reverb_room: 0.7, + ..DepthConfig::BYPASS + }); + assert!(!d.is_bypass()); + let mut peak = 0.0f32; + for i in 0..20_000 { + let x = (i as f32 * 0.03).sin() * 0.4; + let (l, r) = d.process(x); + assert!(l.is_finite() && r.is_finite()); + peak = peak.max(l.abs()).max(r.abs()); + } + assert!(peak < 4.0, "reverb must stay bounded: {peak}"); + } + + #[test] + fn params_round_trip_through_atomics() { + let params = DepthParams::new(); + let cfg = DepthConfig { + enabled: true, + eq_db: [1.0, -2.0, 3.0, -4.0, 5.0], + pan: [0.1, -0.2, 0.3, -0.4, 0.5, -0.6], + reverb_mix: 0.25, + reverb_room: 0.8, + crossfeed: 0.15, + }; + params.store(&cfg); + let got = params.snapshot(); + assert!(got.enabled); + assert_eq!(got.eq_db, cfg.eq_db); + assert_eq!(got.pan, cfg.pan); + assert_eq!(got.reverb_mix, cfg.reverb_mix); + assert_eq!(got.reverb_room, cfg.reverb_room); + assert_eq!(got.crossfeed, cfg.crossfeed); + } + + #[test] + fn corrupt_nan_config_cannot_poison_state() { + let mut d = AudioDepth::new(48_000); + d.apply(&DepthConfig { + enabled: true, + eq_db: [f32::NAN; EQ_BAND_COUNT], + pan: [f32::INFINITY; PAN_COUNT], + reverb_mix: f32::NAN, + reverb_room: f32::NAN, + crossfeed: f32::NAN, + }); + // NaN EQ -> 0 dB (flat), Inf pan clamps to hard right, NaN mix/crossfeed + // -> 0. Output must stay finite. + let (l, r) = d.process(0.5); + assert!(l.is_finite() && r.is_finite()); + } +} diff --git a/crates/rustynes-ios/src/ffi.rs b/crates/rustynes-ios/src/ffi.rs index 93202f606..d469035f9 100644 --- a/crates/rustynes-ios/src/ffi.rs +++ b/crates/rustynes-ios/src/ffi.rs @@ -13,6 +13,7 @@ use core::ffi::c_void; use crate::audio::AudioSink; +use crate::audio_dsp::DepthConfig; use crate::gfx_metal::MetalGfx; // ---- Graphics (Workstream B) ---------------------------------------------- @@ -216,6 +217,58 @@ pub unsafe extern "C" fn rustynes_ios_audio_sample_rate(handle: *mut AudioSink) sink.sample_rate() } +/// `rustynes_ios_audio_set_depth(handle, enabled, eq, eq_len, pan, pan_len, +/// reverb_mix, reverb_room, crossfeed)` — publish the live audio-depth (EQ / pan +/// / reverb / crossfeed) config (v1.9.9). `eq` is up to 5 band gains (dB); `pan` +/// is up to 6 per-channel pans (`-1..=1`). Extra entries are ignored; missing +/// ones default to neutral. `enabled == 0` (or a flat / centered config) is a +/// bit-exact passthrough. No-op on a null handle. +/// +/// # Safety +/// `handle` must be live; `eq` / `pan` must each point to at least their stated +/// length of readable `f32`s (or be null, treated as neutral). +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn rustynes_ios_audio_set_depth( + handle: *mut AudioSink, + enabled: u8, + eq: *const f32, + eq_len: usize, + pan: *const f32, + pan_len: usize, + reverb_mix: f32, + reverb_room: f32, + crossfeed: f32, +) { + if handle.is_null() { + return; + } + // SAFETY: live handle (caller contract). + let sink = unsafe { &*handle }; + let mut cfg = DepthConfig { + enabled: enabled != 0, + reverb_mix, + reverb_room, + crossfeed, + ..DepthConfig::BYPASS + }; + if !eq.is_null() { + // SAFETY: `eq` points to `eq_len` readable `f32`s (caller contract). + let src = unsafe { core::slice::from_raw_parts(eq, eq_len) }; + for (dst, &s) in cfg.eq_db.iter_mut().zip(src.iter()) { + *dst = s; + } + } + if !pan.is_null() { + // SAFETY: `pan` points to `pan_len` readable `f32`s (caller contract). + let src = unsafe { core::slice::from_raw_parts(pan, pan_len) }; + for (dst, &s) in cfg.pan.iter_mut().zip(src.iter()) { + *dst = s; + } + } + sink.set_depth(&cfg); +} + /// `rustynes_ios_audio_pause(handle)` — pause output (scene background / audio /// interruption begin). /// diff --git a/crates/rustynes-ios/src/lib.rs b/crates/rustynes-ios/src/lib.rs index 91319afbc..30b89f7c7 100644 --- a/crates/rustynes-ios/src/lib.rs +++ b/crates/rustynes-ios/src/lib.rs @@ -53,6 +53,16 @@ pub fn core_version() -> String { rustynes_mobile::core_version() } +/// The host audio-depth DSP (v1.9.9 "Workshop"). +/// +/// A host, output-only stereo EQ / pan / reverb / crossfeed stage applied in the +/// CoreAudio callback after the core's mono master is drained. Host-safe (pure +/// `f32` math), so it compiles and is unit-tested on the workspace host build +/// and lints under the host CI; `audio.rs` (iOS-only) wires it into the +/// real-time sink. Bypassable: a default / disabled config is a bit-exact +/// passthrough, so the determinism contract is untouched. +pub mod audio_dsp; + /// The iOS wgpu->Metal render path (Workstream B). iOS-only; pulls in wgpu + the /// `CAMetalLayer` handoff, so it never touches the host shell build. #[cfg(target_os = "ios")] diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 8faa53e0b..3f62400c3 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -115,6 +115,14 @@ pub enum MobileError { /// What went wrong (parse / bind / connect / disconnect detail). reason: String, }, + /// A cheat operation failed (v1.9.9) — a malformed Game Genie code (bad + /// length / character). Raw-RAM pokes/peeks cannot fail, so this only ever + /// covers Game Genie code parsing. + #[error("cheat error: {reason}")] + Cheat { + /// What was wrong with the cheat code. + reason: String, + }, } /// A single NES controller button, used by [`NesController::set_button`] for @@ -445,6 +453,60 @@ enum NetplaySession { InGame(Box>, bool), } +// --- Creator / power tools (v1.9.9 "Workshop") ------------------------------- + +/// One active Game Genie cheat code, marshalled across the FFI (v1.9.9). A flat +/// projection of [`rustynes_core::GenieCode`]. +#[derive(Debug, Clone, uniffi::Record)] +pub struct GenieCodeInfo { + /// The canonical upper-case Game Genie code (6 or 8 characters). + pub code: String, + /// The PRG address (`$8000..=$FFFF`) the code substitutes. + pub addr: u16, + /// The substitute data byte. + pub data: u8, + /// The compare byte (8-character codes only), or `None` for a 6-character + /// code (which substitutes unconditionally). + pub compare: Option, +} + +/// A read-only snapshot of the CPU register file for the debugger inspector +/// (v1.9.9). A flat projection of [`rustynes_core::CpuDebugView`]; purely +/// observational (it never advances or mutates the core). +#[derive(Debug, Clone, Copy, uniffi::Record)] +pub struct CpuRegs { + /// Accumulator. + pub a: u8, + /// X index register. + pub x: u8, + /// Y index register. + pub y: u8, + /// Stack pointer. + pub s: u8, + /// Program counter. + pub pc: u16, + /// Raw processor-status flags (the P register bits). + pub p: u8, + /// `true` when the CPU is jammed on an illegal halt opcode. + pub jammed: bool, + /// Cumulative CPU cycle count since power-on. + pub cycles: u64, +} + +/// One disassembled 6502 instruction for the debugger inspector (v1.9.9). A flat +/// projection of `rustynes_cpu`'s `DisasmLine`. +#[derive(Debug, Clone, uniffi::Record)] +pub struct DisasmRow { + /// The address the instruction starts at. + pub addr: u16, + /// The raw opcode bytes (1..=3). + pub bytes: Vec, + /// The mnemonic (`"LDA"`, `"JMP"`, ...). + pub mnemonic: String, + /// The formatted operand (`"#$42"`, `"$1234,X"`, or empty). + pub operand: String, +} + /// Mutable state behind the controller's lock. struct Inner { nes: Nes, @@ -506,6 +568,16 @@ impl NesController { fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { self.inner.lock().unwrap_or_else(PoisonError::into_inner) } + + /// The loaded ROM's SHA-256 (the foreign-movie-import stamp / netplay + /// handshake key). Not part of the FFI surface — a private helper for the + /// `movie_import_*` methods. + fn rom_sha256(&self) -> [u8; 32] { + let g = self.lock(); + let sha = *g.nes.rom_sha256(); + drop(g); + sha + } } #[uniffi::export] @@ -1494,6 +1566,248 @@ impl NesController { drop(g); status } + + // --- Cheats (v1.9.9 "Workshop") -------------------------------------- + // + // Game Genie codes forward straight into the core's own cheat engine (which + // applies them on every PRG read), so they persist across frames exactly as + // on the desktop. Raw-RAM editing exposes the existing `poke_ram` / `peek` + // core paths (one-shot writes / side-effect-free reads) — the bridge adds NO + // new per-frame mutation, so with no cheats set the build stays byte-identical. + + /// Add a Game Genie code (6 or 8 characters, case-insensitive). The core + /// applies it on every PRG read until removed. Idempotent on the canonical + /// form. + /// + /// # Errors + /// [`MobileError::Cheat`] if the code is not a valid Game Genie code. + pub fn cheat_add_genie(&self, code: String) -> Result<(), MobileError> { + let mut g = self.lock(); + let r = g.nes.add_genie_code(&code).map_err(|e| MobileError::Cheat { + reason: e.to_string(), + }); + drop(g); + r + } + + /// Remove a previously-added Game Genie code (no-op if not present). + pub fn cheat_remove_genie(&self, code: String) { + let mut g = self.lock(); + g.nes.remove_genie_code(&code); + drop(g); + } + + /// Remove every active Game Genie code. + pub fn cheat_clear_genie(&self) { + let mut g = self.lock(); + g.nes.clear_genie_codes(); + drop(g); + } + + /// The currently-active Game Genie codes. + pub fn cheat_genie_codes(&self) -> Vec { + let g = self.lock(); + let codes = g + .nes + .genie_codes() + .map(|c| GenieCodeInfo { + code: c.code().to_string(), + addr: c.addr(), + data: c.data(), + compare: c.compare(), + }) + .collect(); + drop(g); + codes + } + + /// Write one byte into CPU RAM (`$0000..=$1FFF`, the 2 KiB internal RAM and + /// its mirrors) via the core's existing `poke_ram` path — the raw-RAM editor + /// affordance. A one-shot write (the game may overwrite it next frame); it + /// adds no per-frame mutation surface. + /// + /// Defense-in-depth: the address is masked to the documented `$0000..=$1FFF` + /// internal-RAM mirror region here at the bridge boundary, so the bound is + /// enforced by the bridge itself rather than relying on the core. + pub fn poke_ram(&self, addr: u16, value: u8) { + let addr = addr & 0x1FFF; + let mut g = self.lock(); + g.nes.poke_ram(addr, value); + drop(g); + } + + /// Read one byte from the CPU bus at `addr`, side-effect-free (the core's + /// `peek`). The single-address convenience over [`Self::debug_read_memory`]. + pub fn peek_byte(&self, addr: u16) -> u8 { + let mut g = self.lock(); + let v = g.nes.peek(addr); + drop(g); + v + } + + // --- Read-only debugger inspector (v1.9.9 "Workshop") ---------------- + // + // Purely observational: every method snapshots core state without advancing + // or mutating emulation, so the determinism contract is untouched. The host + // gates the debugger UI off the App-Store build. + + /// A read-only snapshot of the CPU registers (`rustynes_core::CpuDebugView`). + pub fn debug_cpu_state(&self) -> CpuRegs { + let g = self.lock(); + let v = g.nes.cpu_snapshot(); + drop(g); + CpuRegs { + a: v.a, + x: v.x, + y: v.y, + s: v.s, + pc: v.pc, + p: v.p, + jammed: v.jammed, + cycles: v.cycles, + } + } + + /// Read `len` bytes from the CPU bus starting at `start` (wrapping at the + /// 64 KiB boundary), side-effect-free. `len` is capped at 64 KiB so a + /// malformed request can never over-allocate. Feeds the debugger hex view. + pub fn debug_read_memory(&self, start: u16, len: u32) -> Vec { + let len = (len as usize).min(0x1_0000); + let mut g = self.lock(); + let mut out = Vec::with_capacity(len); + let mut addr = start; + for _ in 0..len { + out.push(g.nes.peek(addr)); + addr = addr.wrapping_add(1); + } + drop(g); + out + } + + /// Disassemble `count` 6502 instructions starting at `pc` (the debugger code + /// view). `count` is capped at 256. Reads a bounded byte window with the + /// side-effect-free `peek`, so the disassembler runs over an owned buffer and + /// never re-enters the core. + pub fn debug_disassemble(&self, pc: u16, count: u32) -> Vec { + let count = (count as usize).min(256); + // Up to 3 bytes per instruction, plus a small tail so the final + // instruction's operand is fully readable; capped at the address space. + let window = count.saturating_mul(3).saturating_add(3).min(0x1_0000); + let mut g = self.lock(); + let mut buf = vec![0u8; window]; + let mut addr = pc; + for slot in &mut buf { + *slot = g.nes.peek(addr); + addr = addr.wrapping_add(1); + } + drop(g); + let lines = rustynes_core::rustynes_cpu::disasm::disassemble_at( + |a| { + let off = a.wrapping_sub(pc) as usize; + buf.get(off).copied().unwrap_or(0) + }, + pc, + count, + ); + lines + .into_iter() + .map(|l| DisasmRow { + addr: l.addr, + bytes: l.bytes, + mnemonic: l.mnemonic.to_string(), + operand: l.operand, + }) + .collect() + } + + // --- Foreign movie import (v1.9.9 "Workshop") ------------------------ + // + // Each importer transcodes a foreign movie into the native `.rnm` byte + // stream stamped with the CURRENTLY-loaded ROM's hash, then returns those + // bytes — the host plays them via the existing [`Self::movie_play`] and/or + // saves them as a `.rnm`. The core importers validate their input and return + // a `Result` (they never panic on a malformed file); the only parsing the + // bridge itself does is the `.bk2` ZIP member extraction below, which caps + // member sizes. The produced movie is for the loaded ROM regardless of which + // game the foreign file was authored against (it transcodes the input log). + + /// Import an `FCEUX` `.fm2` movie (UTF-8 text) and return the native `.rnm` + /// bytes for the loaded ROM. + /// + /// # Errors + /// [`MobileError::Movie`] if the bytes are not valid UTF-8 or not a parseable + /// `.fm2`. + pub fn movie_import_fm2(&self, bytes: Vec) -> Result, MobileError> { + let text = String::from_utf8(bytes).map_err(|e| MobileError::Movie { + reason: format!("fm2 is not valid UTF-8: {e}"), + })?; + let rom_sha = self.rom_sha256(); + let (movie, _meta) = + rustynes_core::movie_interop::import_fm2(&text, rom_sha).map_err(|e| { + MobileError::Movie { + reason: e.to_string(), + } + })?; + Ok(movie.serialize()) + } + + /// Import a `BizHawk` `.bk2` movie (a ZIP archive containing `Header.txt` and + /// `Input Log.txt`) and return the native `.rnm` bytes for the loaded ROM. + /// + /// # Errors + /// [`MobileError::Movie`] if the archive is malformed, is missing either + /// member, or the input log does not parse. + pub fn movie_import_bk2(&self, bytes: Vec) -> Result, MobileError> { + let (header, input_log) = read_bk2_members(&bytes)?; + let rom_sha = self.rom_sha256(); + let (movie, _meta) = rustynes_core::bk2_interop::import_bk2(&header, &input_log, rom_sha) + .map_err(|e| MobileError::Movie { + reason: e.to_string(), + })?; + Ok(movie.serialize()) + } + + /// Import a Nestopia `.fcm` movie and return the native `.rnm` bytes for the + /// loaded ROM. + /// + /// # Errors + /// [`MobileError::Movie`] if the bytes are not a parseable `.fcm`. + pub fn movie_import_fcm(&self, bytes: Vec) -> Result, MobileError> { + let rom_sha = self.rom_sha256(); + let (movie, _meta) = + rustynes_core::import_fcm(&bytes, rom_sha).map_err(|e| MobileError::Movie { + reason: e.to_string(), + })?; + Ok(movie.serialize()) + } + + /// Import a Famtasia `.fmv` movie and return the native `.rnm` bytes for the + /// loaded ROM. + /// + /// # Errors + /// [`MobileError::Movie`] if the bytes are not a parseable `.fmv`. + pub fn movie_import_fmv(&self, bytes: Vec) -> Result, MobileError> { + let rom_sha = self.rom_sha256(); + let (movie, _meta) = + rustynes_core::import_fmv(&bytes, rom_sha).map_err(|e| MobileError::Movie { + reason: e.to_string(), + })?; + Ok(movie.serialize()) + } + + /// Import a `VirtuaNES` `.vmv` movie and return the native `.rnm` bytes for the + /// loaded ROM. + /// + /// # Errors + /// [`MobileError::Movie`] if the bytes are not a parseable `.vmv`. + pub fn movie_import_vmv(&self, bytes: Vec) -> Result, MobileError> { + let rom_sha = self.rom_sha256(); + let (movie, _meta) = + rustynes_core::import_vmv(&bytes, rom_sha).map_err(|e| MobileError::Movie { + reason: e.to_string(), + })?; + Ok(movie.serialize()) + } } /// If `bytes` is a ZIP archive (PK magic), extract the first NES-format entry @@ -1536,6 +1850,55 @@ fn decompress_rom(bytes: Vec) -> Vec { extracted.unwrap_or(bytes) } +/// Extract a `.bk2`'s `Header.txt` + `Input Log.txt` members (v1.9.9). A `.bk2` +/// is a ZIP archive; this opens it, finds the two members by name +/// (case-insensitive), and reads each with a hard size cap so a malformed / +/// hostile archive can never OOM the app. Returns `(header, input_log)` text. +/// +/// # Errors +/// [`MobileError::Movie`] if the bytes are not a ZIP, either member is missing / +/// oversized, or a member is not valid UTF-8. +fn read_bk2_members(bytes: &[u8]) -> Result<(String, String), MobileError> { + use std::io::Read; + // A `.bk2` text log is small; cap each member generously so a zip bomb can't + // blow up. 16 MiB is far beyond any real movie's Header/Input Log. + const MAX_MEMBER_BYTES: u64 = 16 * 1024 * 1024; + let err = |reason: String| MobileError::Movie { reason }; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|e| err(format!("not a valid .bk2 (zip) archive: {e}")))?; + + let mut read_member = |wanted: &str| -> Result { + // Match the member name case-insensitively on its final path component so + // a `Header.txt` / `header.txt` / nested entry all resolve. + let idx = (0..archive.len()) + .find(|&i| { + archive.by_index(i).is_ok_and(|e| { + std::path::Path::new(e.name()) + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case(wanted)) + }) + }) + .ok_or_else(|| err(format!(".bk2 is missing '{wanted}'")))?; + let entry = archive + .by_index(idx) + .map_err(|e| err(format!("cannot read '{wanted}': {e}")))?; + if entry.size() > MAX_MEMBER_BYTES { + return Err(err(format!("'{wanted}' is implausibly large"))); + } + let mut buf = Vec::new(); + entry + .take(MAX_MEMBER_BYTES) + .read_to_end(&mut buf) + .map_err(|e| err(format!("cannot read '{wanted}': {e}")))?; + String::from_utf8(buf).map_err(|e| err(format!("'{wanted}' is not valid UTF-8: {e}"))) + }; + + let header = read_member("Header.txt")?; + let input_log = read_member("Input Log.txt")?; + Ok((header, input_log)) +} + /// Apply movie playback (drive input from the loaded movie) and recording (capture /// the upcoming frame's input) around a tick. Called holding the lock, immediately /// before `Nes::run_frame`. @@ -1910,6 +2273,122 @@ mod tests { assert_eq!(info.region, NesRegion::Ntsc); } + // --- Creator / power tools (v1.9.9 "Workshop") ----------------------- + + #[test] + fn genie_codes_round_trip() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + assert!(ctrl.cheat_genie_codes().is_empty()); + // "GOSSIP" is a canonical valid 6-character Game Genie code. + ctrl.cheat_add_genie("GOSSIP".to_string()) + .expect("valid genie code"); + let codes = ctrl.cheat_genie_codes(); + assert_eq!(codes.len(), 1); + assert_eq!(codes[0].code, "GOSSIP"); + assert!((0x8000..=0xFFFF).contains(&codes[0].addr)); + ctrl.cheat_remove_genie("GOSSIP".to_string()); + assert!(ctrl.cheat_genie_codes().is_empty()); + } + + #[test] + fn genie_rejects_bad_code() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + match ctrl.cheat_add_genie("NOPE".to_string()) { + Err(MobileError::Cheat { .. }) => {} + other => panic!("expected Cheat error, got {other:?}"), + } + assert!(ctrl.cheat_genie_codes().is_empty()); + } + + #[test] + fn raw_ram_poke_peek_round_trips() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + ctrl.poke_ram(0x0010, 0x42); + assert_eq!(ctrl.peek_byte(0x0010), 0x42); + } + + #[test] + fn debug_cpu_state_is_observational() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + ctrl.step_frame(); + let before = ctrl.frame(); + let regs = ctrl.debug_cpu_state(); + // The tight `JMP $8000` loop keeps the PC parked in the reset window. + assert!(!regs.jammed); + assert!((0x8000..=0x8002).contains(®s.pc)); + // Reading debug state must NOT advance the core. + assert_eq!(ctrl.frame(), before); + } + + #[test] + fn debug_read_memory_caps_length_and_reads_prg() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + let win = ctrl.debug_read_memory(0x8000, 16); + assert_eq!(win.len(), 16); + assert_eq!(win[0], 0x4C, "PRG at $8000 is the JMP opcode"); + // An over-large request is capped at the 64 KiB address space. + assert_eq!(ctrl.debug_read_memory(0, u32::MAX).len(), 0x1_0000); + } + + #[test] + fn debug_disassemble_decodes_reset_loop() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + let rows = ctrl.debug_disassemble(0x8000, 4); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0].addr, 0x8000); + assert_eq!(rows[0].mnemonic, "JMP"); + // The count is capped so a hostile request can't allocate unbounded. + assert_eq!(ctrl.debug_disassemble(0x8000, u32::MAX).len(), 256); + } + + #[test] + fn foreign_movie_import_errors_gracefully() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + // Malformed input must surface a clean Movie error, never panic / OOB. + assert!(matches!( + ctrl.movie_import_fm2(vec![0xFF, 0xFE, 0xFF]), + Err(MobileError::Movie { .. }) + )); + assert!(matches!( + ctrl.movie_import_bk2(b"not a zip".to_vec()), + Err(MobileError::Movie { .. }) + )); + assert!(matches!( + ctrl.movie_import_fcm(b"garbage".to_vec()), + Err(MobileError::Movie { .. }) + )); + assert!(matches!( + ctrl.movie_import_fmv(b"garbage".to_vec()), + Err(MobileError::Movie { .. }) + )); + assert!(matches!( + ctrl.movie_import_vmv(b"garbage".to_vec()), + Err(MobileError::Movie { .. }) + )); + } + + // The happy path: a minimal but valid `.fm2` transcodes to non-empty native + // `.rnm` bytes that `movie_play` accepts. The header needs only a leading + // `version 3` line (per `import_fm2`'s required-first-key contract); the + // ROM's SHA-256 is supplied internally by the bridge (not carried in the + // `.fm2`), so any loaded ROM works. Two input-log lines (`|c|p0|p1|port2|`, + // each pad an 8-char `RLDUTSBA` field) exercise the frame path: line 1 is + // all-released, line 2 presses A (last column). + #[test] + fn fm2_import_happy_path_transcodes_and_plays() { + let ctrl = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + let fm2 = "version 3\n\ + |0|........|........||\n\ + |0|.......A|........||\n"; + let rnm = ctrl + .movie_import_fm2(fm2.as_bytes().to_vec()) + .expect("minimal valid .fm2 must transcode"); + assert!(!rnm.is_empty(), "transcode must yield native .rnm bytes"); + // The produced movie is power-on anchored against the loaded ROM, so + // playback must accept it. + ctrl.movie_play(rnm).expect("native .rnm must replay"); + } + // v1.8.6 — the RA bridge surfaces the lazy session + the login lifecycle. #[test] fn ra_session_created_lazily_and_hardcore_round_trips() { diff --git a/docs/STATUS.md b/docs/STATUS.md index 9f20cf5c6..228e0bdd2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,12 +1,22 @@ # RustyNES — Project Status Matrix -> **Current release: v1.9.8 "Horizon"** (2026-06-26) — the iOS store-readiness pass: -> **accessibility** (VoiceOver / Dynamic Type / high-contrast + Okabe-Ito palettes), -> **EN/ES i18n**, **ReplayKit** capture, opt-in **Game Center** sign-in, a **§4.7 + +> **Current release: v1.9.9 "Workshop"** (2026-06-26) — the iOS creator / power-tools +> release + the final pre-Timebase readiness gate: a **Cheats** editor (Game Genie + +> raw-RAM peek/poke), a FOSS-build-gated **read-only debugger inspector** (CPU regs / +> disassembly / hex / frame-step), a touch **TAStudio piano-roll**, **foreign movie +> import** (`.fm2`/`.bk2`/`.fcm`/`.fmv`/`.vmv` → native `.rnm`), host-side **audio +> depth** (5-band EQ / panning / reverb / crossfeed, bypassable to bit-identical), and +> host-side **symbol maps** (`.sym`/`.mlb`/`.nl`). The bridge gains only **additive +> forwarding fns** over existing core APIs — the deterministic core / chip crates are +> untouched, so byte-identity + **AccuracyCoin 139/139** hold. Mobile ROM loading is +> **iNES / NES 2.0 only** (FDS / NSF + 20-band EQ + `.dbg` source maps are post-v2.0.0 +> carryovers). This release shipped a full readiness pass (host gates, security audit, +> gap analysis, completeness-critic) — see `docs/ios-v1.9.9-readiness.md`. The +> preceding **v1.9.8 "Horizon"** — the iOS store-readiness pass: **accessibility** +> (VoiceOver / Dynamic Type / high-contrast + Okabe-Ito palettes), **EN/ES i18n**, +> **ReplayKit** capture, opt-in **Game Center** sign-in, a **§4.7 + > `PrivacyInfo.xcprivacy`** disclosure pass, and the **dormant StoreKit / `foss`-vs- -> App-Store seam** (the real split is v2.1.0 per ADR 0027). All SwiftUI-shell work; -> the core is untouched (only `CARGO_PKG_VERSION` moves 1.9.7 → 1.9.8), so -> byte-identity + AccuracyCoin 139/139 hold. The preceding **v1.9.7 "Relay"** — +> App-Store seam** (the real split is v2.1.0 per ADR 0027). The preceding **v1.9.7 "Relay"** — > **room-code (CGNAT/TURN) netplay**, **robust GameController hot-plug**, and opt-in > **iCloud save-state sync** (CloudKit). The preceding **v1.9.6 "Link"** — a **Lua** > console, @@ -133,10 +143,14 @@ > output widening) · 0021 (File System Access fallback) · 0022 (settings share-link) · > 0023 (i18n string-catalog). -**Current release: v1.9.8 "Horizon"** (the iOS store-readiness pass — accessibility, -EN/ES i18n, ReplayKit, Game Center, the §4.7 + privacy-manifest pass, and the dormant -StoreKit / foss-seam, framed in the blockquote above), on the cycle-accurate v1.0.0 -production core; the preceding **v1.9.7 "Relay"** was the connectivity-completion +**Current release: v1.9.9 "Workshop"** (the iOS creator / power-tools release + the +final pre-Timebase readiness gate — a Cheats editor, a FOSS-gated read-only debugger +inspector, a touch TAStudio piano-roll, foreign movie import, host-side audio depth, +and symbol maps, framed in the blockquote above; additive bridge forwarding fns only, +core byte-identical, AccuracyCoin 139/139), on the cycle-accurate v1.0.0 production +core; the preceding **v1.9.8 "Horizon"** was the iOS store-readiness pass +(accessibility, EN/ES i18n, ReplayKit, Game Center, the §4.7 + privacy-manifest pass, +and the dormant StoreKit / foss-seam); the preceding **v1.9.7 "Relay"** was the connectivity-completion release (room-code CGNAT/TURN netplay, controller hot-plug robustness, iCloud save-state sync via CloudKit); the preceding **v1.9.6 "Link"** was the iOS connectivity release (Lua console, diff --git a/docs/ios-v1.9.9-readiness.md b/docs/ios-v1.9.9-readiness.md new file mode 100644 index 000000000..f6f3e021d --- /dev/null +++ b/docs/ios-v1.9.9-readiness.md @@ -0,0 +1,141 @@ +# RustyNES v1.9.9 "Workshop" — iOS readiness gate + +This is the readiness record for **v1.9.9 "Workshop"**, the final iOS/iPadOS +TestFlight release before the **v2.0.0 "Timebase"** core rewrite. v1.9.9 freezes the +iOS line for the v2.0.x re-port, so beyond shipping the creator/power-tools it runs a +full pre-freeze readiness pass: host-gate verification, a security audit, a gap +analysis against the train plan + Android, and a completeness-critic sweep. This file +is the authoritative summary; `docs/STATUS.md` remains the per-suite source of truth. + +All v1.9.9 work is **additive / off-by-default**. The bridge gains only new forwarding +fns over existing `rustynes-core` APIs (no signature or behaviour change); the +deterministic core and chip crates are untouched; the shipped / native / `no_std` / +wasm core stays **byte-identical** and **AccuracyCoin holds 100% (139/139)**. + +## 1. Verification / validation + +### Host gate suite (all green) + +| Gate | Result | +|---|---| +| `cargo fmt --all --check` | PASS | +| `cargo clippy --workspace --all-targets -- -D warnings` | PASS | +| `cargo clippy -p rustynes-frontend --features scripting -- -D warnings` | PASS | +| `cargo clippy -p rustynes-frontend --features scripting,hd-pack -- -D warnings` | PASS | +| `cargo clippy -p rustynes-frontend --features retroachievements -- -D warnings` | PASS | +| `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` | PASS | +| `cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features` (`no_std`) | PASS | +| `cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --lib --bins -- -D warnings` | PASS | +| wasm `--no-default-features --features wasm-canvas` clippy | PASS | +| `cargo test --workspace --features test-roms` (AccuracyCoin / blargg / kevtris) | PASS — **139/139** | +| `markdownlint` (pinned v0.39.0, changed docs) | PASS | +| `rustynes-mobile` / `rustynes-ios` unit tests | PASS (24 + 8 + the new transcode test) | + +The bridge change is verified additive: `cargo build --workspace` compiles the Android +crate unchanged, and no existing fn signature was modified. + +### Feature reachability + +Every new bridge fn has a Swift caller; every new view (Cheats, Debugger, TAStudio, +audio-depth controls, foreign-movie import) is reachable from the pill menu / Settings / +movie panel. The audio-depth DSP is bypassable to a bit-identical passthrough (asserted +by `to_bits()` equality tests). The debugger inspector is gated to the FOSS / TestFlight +build and is unreachable on the App-Store channel (ADR 0027). + +Swift / iOS build + on-device validation are **not** CI-certifiable on the Linux host and +remain the documented TestFlight carryover (see §4). + +## 2. Security audit + +**Verdict: shippable — no Critical/High findings, nothing release-blocking.** The new +creator/power-tools surface is well-bounded and memory-safe; credentials are +Keychain-isolated; the importer bounds, FFI `unsafe` invariants, SPSC audio ring, and +TAStudio lock ordering all hold under verification. + +| ID | Severity | Item | Disposition | +|---|---|---|---| +| M1 | Medium | Synchronous file I/O on the main thread (ROM / symbol import paths) — a UI-availability nuisance, not a vulnerability | Fixed (moved to `Task.detached`) | +| L1 | Low | Synchronous palette read on main thread | Fixed (moved off-main) | +| I1 | Info | `poke_ram` relied on the core to bound the address | Fixed (bridge-side `addr & 0x1FFF` clamp) | + +Verified clean: all importer bounds (64 KiB memory-read cap, 256-row disasm cap, 16 MiB +per-ZIP-member cap with both a size check and a `.take()`), graceful (never-panicking) +parse errors on malformed movie input, the depth-config atomic mailbox (NaN/Inf mapped +to neutral), every `ffi.rs` `unsafe` with a sound `// SAFETY:` invariant, Keychain-backed +RA token + TURN secret (only non-secret config in UserDefaults), no hardcoded secrets, no +ATS bypass, and a `PrivacyInfo.xcprivacy` that still matches the code (creator tools add +no data collection). + +## 3. Gap analysis + +### Train-plan coverage + +The v1.9.0–v1.9.8 workstreams are all present and corroborated by the CHANGELOG and +STATUS. The v1.9.9 "Workshop" workstreams: debugger overlay (read-only, FOSS-gated), +TAStudio piano-roll, foreign movie import (all five formats), Game Genie cheats, raw-RAM +cheats (one-shot), and audio depth are **implemented**; the scoping below is intentional +and acceptable for a TestFlight-readiness release. + +### iOS-vs-Android parity + +At parity on all emulation, input, library, video, netplay, RA, Lua, cloud, +accessibility, capture, and i18n features. Android-only items that are intentionally N/A +on iOS (QuickTile, home-screen widget / deep-link resume, Play Integrity, Play In-App +Updates, Chromecast) map to platform services the App Store handles differently. Genuine +iOS-applicable deferrals: box-art scraping, an opt-in crash-reporting surface, and a +dedicated external-display output — all post-v2.0.0. + +### Creator-tools completeness (intentional scoping) + +- Debugger: read-only inspector + single-frame step; no breakpoints / instruction-step / + register-write (the bridge exposes only observational reads). +- Symbols: host-side `.sym` / `.mlb` / `.nl` parsing; `.dbg` (ca65/cc65 source maps) is a + carryover. +- Raw-RAM cheats: one-shot poke/peek; Game Genie covers persistent cheating. +- TAStudio: host-side P1 frame table + scripted playback, not an editor over an arbitrary + loaded movie. +- Audio depth: 5-band EQ (the 20-band desktop EQ is a carryover); no NSF player. + +### FDS / NSF (resolved this release) + +The shared mobile bridge loads **iNES / NES 2.0 only** — it has no FDS-disk or NSF-player +entry point (true for Android too). The import picker and `Info.plist` previously +advertised `.fds` / `.nsf`, which would fail on selection. v1.9.9 trims the picker + +document types to NES (+ `.zip`) for honesty; FDS + NSF on mobile are a post-v2.0.0 +carryover. + +## 4. Completeness-critic findings + +No merge-blocking defects. Polish items found and dispositioned: + +| Item | Disposition | +|---|---| +| TAStudio "Frame" column header + interpolated accessibility label not localized | Fixed (EN/ES keys added) | +| Audio-depth slider labels never localized (`ParamSlider.title` was a plain `String`) | Fixed (`LocalizedStringKey` + EN/ES keys) | +| `importForeignMovie` transcode ran on the main actor | Fixed (transcode moved into the detached task) | +| Hex `TextField`s (Cheats / Debugger) lacked VoiceOver labels | Fixed (`accessibilityLabel` added) | +| TAStudio `.rnm` export captured trailing idle frames | Fixed (stop-at-exhaustion / tail trim) | +| TAStudio "Save" power-cycles silently | Fixed (added a localized caption) | +| No happy-path foreign-movie transcode test at the bridge | Fixed (added an `Ok` transcode test) | + +## 5. Carryovers to v2.0.x / v2.1.0 + +**Forced re-ports by the v2.0.0 save-state-format break (ADR 0002):** `.rns` +save-states + the four slots + CloudKit-synced states, `.rnm` TAS movies / imports, and +cross-platform save + netplay parity must be re-validated on the new core. iOS host +re-port lands v2.0.5–v2.0.8; dual-app readiness v2.0.9. + +**Distribution / monetization (v2.1.0, joint with Android):** launch the App-Store +channel plus AltStore PAL, and wire `Entitlements.swift`'s dormant `StoreManager` to +`rustynes-monetization` (StoreKit 2 + RevenueCat + AppLovin MAX, ATT). Dormant / +fully-unlocked through v1.9.x. + +**Maintainer-manual (not CI-certifiable):** Apple Developer account + bundle ID, fastlane +match signing + App Store Connect API key, app icon / launch art, and the full on-device +TestFlight verification checklist (ROM import, save / rewind, MFi controller, audio +interruptions across iOS 16/17/18, ProMotion pacing, no crashes on 5–10 ROMs, an accurate +privacy label). + +**Deferred features to re-evaluate post-Timebase:** FDS disk + NSF player, 20-band EQ, +`.dbg` source maps, a cheats database, box-art scraping, a home-screen widget, a dedicated +external-display output, and an opt-in crash-reporting surface. diff --git a/docs/ios.md b/docs/ios.md index 47040674c..2b7c93349 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -139,6 +139,50 @@ AVFoundation / UIKit, and includes the generated `Generated/RustyNESCore.swift` pauses the loop / audio and drops the drawable on background, rebuilding on foreground. `PrivacyInfo.xcprivacy` declares no data collected. +### Creator / power tools (v1.9.9 "Workshop") + +The final iOS TestFlight release before the v2.0.0 core rewrite adds a +creator/power-tools set. Every piece is **additive / opt-in** and forwards to +existing core APIs through additive `rustynes-mobile` bridge functions, so with +the tools unused the app behaves as v1.9.8 and AccuracyCoin holds 139/139 (the +determinism contract is untouched). + +- **Cheats (`CheatsView`, in-game pill menu):** add / remove / clear / list Game + Genie codes (the core's own cheat engine applies them live to PRG reads, + exactly like the desktop) plus a raw-RAM editor that pokes / peeks a CPU-RAM + byte (`$0000-$1FFF`) through the existing `poke_ram` / side-effect-free `peek` + paths. No per-frame mutation is added, so an empty cheat set is byte-identical. +- **Read-only debugger inspector (`DebuggerView`), gated OFF the App-Store + build** via `BuildChannel` (the FOSS / TestFlight channel only, ADR 0027): a + CPU register view (`cpu_snapshot`), a disassembly around the PC + (`rustynes-cpu`'s `disassemble_at` over a bounded byte window), and a CPU-RAM + hex view (`peek`). All observational (never advances / mutates the core); a + single "Step" advances exactly one frame while the inspector holds the emulator + paused. Optional `.sym` / `.mlb` / `.nl` symbol files annotate the disassembly + (parsed host-side in `SymbolMap.swift` — the core exposes no symbol API and the + mobile bridge does not depend on the frontend's parser). +- **Touch TAStudio piano-roll (`TAStudioView`):** a frame-by-frame P1 input table + (8 button columns). "Play" injects the table one mask per frame through the + existing bridge (`setButtons` + `runFrame`, deterministic); "Save .rnm" arms + the core's recorder (`movie_record_from_power_on`) and replays the table so the + captured input is written to a real native `.rnm` movie. No movie-editing API + is added to the core. +- **Foreign movie import (`MoviePanelView` + `UIDocumentPicker`):** FCEUX `.fm2`, + BizHawk `.bk2`, Nestopia `.fcm`, Famtasia `.fmv`, and VirtuaNES `.vmv` are + transcoded to a native `.rnm` for the loaded game (the bridge calls the core's + `import_fm2` / `import_bk2` / `import_fcm` / `import_fmv` / `import_vmv`, + serialising the result), then saved + played via the existing movie surface. + Malformed files error gracefully (bounds-checked; the `.bk2` ZIP member + extraction caps member sizes), never crash. +- **Host audio-depth DSP (Settings -> "Audio depth"):** an **output-only** stereo + enrichment stage (5-band EQ, pan, Schroeder reverb, headphone crossfeed) ported + from the desktop frontend, applied in the CoreAudio callback *after* the core's + mono master is drained — never in the synthesis. It lives in the host-safe + `crates/rustynes-ios/src/audio_dsp.rs` (unit-tested on the host build) and is + wired into the cpal sink (`audio.rs`) over a lock-free atomic config mailbox. A + disabled / flat / centered config is a **bit-exact passthrough**, so the audio + oracle and save portability are preserved. + ## Build pipeline (`scripts/build-ios-xcframework.sh`) `rustup target add` the iOS targets -> `cargo build --release -p rustynes-ios` @@ -177,16 +221,29 @@ with Android, after the v2.0.0 "Timebase" core rewrite) live in `docs/adr/0027-ios-distribution-and-app-store-compliance.md` and `to-dos/plans/v2.0.x-mobile-finalization-plan.md`. -## v1.9.0 status + carryovers +## v1.9.x status + carryovers -v1.9.0 "Sunrise" lands the **foundation**: the `rustynes-ios` shim, the SwiftUI +v1.9.0 "Sunrise" landed the **foundation**: the `rustynes-ios` shim, the SwiftUI app, the xcframework build + tag-gated macOS CI + fastlane, and the MVP feature set (core + Metal video + CoreAudio + touch / controller + save-states / rewind / -run-ahead / TAS-playback). **Lua / RetroAchievements / netplay are deferred** to -the v1.9.x train (the shared bridge already exposes them, so they reduce to -SwiftUI chrome — see the train plan). The host gates (fmt / clippy `-D warnings` / -rustdoc / `no_std` / wasm) stay green and AccuracyCoin holds 139/139, because the -crate is a host shell off-device. +run-ahead / TAS-playback). The v1.9.x train then shipped the deferred connectivity +and scripting: **Lua scripting (v1.9.6), RetroAchievements (v1.9.6), and netplay +(v1.9.6/v1.9.7)** are all live (the shared bridge already exposed them, so each +reduced to SwiftUI chrome), plus iCloud save-state sync (v1.9.7) and the +Google-Play-parity polish (v1.9.8). The current line is **v1.9.9 "Workshop"** — +the creator / power-tools release (read-only debugger inspector, raw-RAM editor, +Game Genie codes, foreign-movie import `.fm2`/`.bk2`/`.fcm`/`.fmv`/`.vmv`, the +TAStudio piano-roll, custom palettes, HD-packs, and the audio-depth controls). The +host gates (fmt / clippy `-D warnings` / rustdoc / `no_std` / wasm) stay green and +AccuracyCoin holds 139/139, because the crate is a host shell off-device. + +**Explicitly NOT on the iOS bridge (post-v2.0.0 carryovers).** The mobile bridge is +iNES / NES 2.0-only, so **FDS disk images (`.fds`) and NSF music files (`.nsf`) +cannot be loaded** — the picker + Info.plist advertise only `.nes` (+ `.zip`), and +there is correspondingly **no NSF player**. The native desktop **20-band EQ** (the +mobile audio-depth panel ships a 5-band EQ) and the debugger's **`.dbg` ca65/cc65 +source maps** are likewise deferred to the post-v2.0.0 mobile re-port. These land +when the iOS app re-ports onto the v2.0.0 "Timebase" core. **Maintainer-manual carryovers** (cannot be CI-self-certified, mirroring the Android line): an Apple Developer Program account + bundle ID; the signing secrets diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index 23fdd8313..d857cd792 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -13,9 +13,11 @@ import SwiftUI /// Small app-level errors surfaced to the user (v1.9.6). enum AppError: LocalizedError { case noGame + case fileAccessDenied var errorDescription: String? { switch self { case .noGame: return String(localized: "No game is running.") + case .fileAccessDenied: return String(localized: "Could not access the selected file.") } } } @@ -41,6 +43,10 @@ final class AppModel: ObservableObject { let ra = RetroAchievementsModel() let netplay = NetplayModel() + // Creator / power tools (v1.9.9 "Workshop"). Host output-only audio DSP; + // off by default (a flat / disabled config is a bit-exact passthrough). + let audioDepth = AudioDepthModel() + // Store-readiness additions (v1.9.8). Gameplay capture (ReplayKit) + opt-in Game // Center sign-in; both no-op gracefully when unavailable. let recorder = ScreenRecorder() @@ -228,6 +234,10 @@ final class AppModel: ObservableObject { } gamepads.start() + // Re-apply the host audio-depth DSP to the running core whenever a setting + // changes (no-op when no game is running). v1.9.9. + audioDepth.onChange = { [weak self] in self?.applyAudioDepth() } + // Check the iCloud account status at launch so the save-state sync indicator // and reconciliation are ready by the time a game opens (a no-op when the // sync toggle is off / iCloud is unavailable). @@ -241,10 +251,11 @@ final class AppModel: ObservableObject { // MARK: - Session lifecycle /// Open a library entry: read its bytes, build a fresh EmulatorCore, and start. - func openGame(_ entry: LibraryEntry) { + /// The ROM read runs off the main actor (see `ROMLibrary.romData`). + func openGame(_ entry: LibraryEntry) async { do { audioSession.configure() - let data = try library.romData(for: entry) + let data = try await library.romData(for: entry) let core = try EmulatorCore(romData: data) core.isMuted = muted // Tear any prior session's connectivity state down before freeing its core @@ -258,6 +269,7 @@ final class AppModel: ObservableObject { // Apply this game's per-game overrides if any, else the global defaults // (filter + shader params + palette + HD-pack). applyDisplaySettings() + applyAudioDepth() syncMovieState() // Wire connectivity & scripting into the fresh core (no-ops when disabled). ra.attach(core: core, romData: data, sha: entry.sha) @@ -269,11 +281,12 @@ final class AppModel: ObservableObject { } } - /// Import a ROM from a picked URL and immediately open it. - func importAndOpen(_ url: URL) { + /// Import a ROM from a picked URL and immediately open it. The import read + + /// the subsequent ROM read both run off the main actor. + func importAndOpen(_ url: URL) async { do { - let entry = try library.importROM(from: url) - openGame(entry) + let entry = try await library.importROM(from: url) + await openGame(entry) } catch { errorMessage = "Import failed: \(error.localizedDescription)" } @@ -316,6 +329,89 @@ final class AppModel: ObservableObject { var luaIsLoaded: Bool { emulator?.scriptIsLoaded ?? false } func drainLuaLog() -> [String] { emulator?.drainScriptLog() ?? [] } + // MARK: - Audio depth (v1.9.9) + + /// Apply the current host audio-depth (EQ / pan / reverb / crossfeed) config to + /// the running core (no-op when no game is running). + func applyAudioDepth() { audioDepth.apply(to: emulator) } + + // MARK: - Foreign movie import (v1.9.9) + + /// Import a foreign movie (`.fm2` / `.bk2` / `.fcm` / `.fmv` / `.vmv`) at `url`, + /// transcode it to a native `.rnm` for the running game, save it under the game's + /// name, and start playback. The file read runs off the main actor; the core call + /// + state update hop back. A malformed file surfaces a clean error (never a crash). + func importForeignMovie(at url: URL) { + guard let emulator else { errorMessage = String(localized: "No game is running."); return } + let ext = url.pathExtension.lowercased() + // Reject unsupported extensions synchronously on the main actor. + guard ["fm2", "bk2", "fcm", "fmv", "vmv"].contains(ext) else { + errorMessage = String( + format: String(localized: "Unsupported movie format: %@"), ext + ) + return + } + Task { + // Acquire the security scope on the main actor and hold it across the + // off-main read. Security-scoped access is actor-bound, so it must NOT be + // started inside the detached task. The detached task only reads bytes + // (`Data` is Sendable); the non-Sendable `emulator` never crosses into it. + guard url.startAccessingSecurityScopedResource() else { + self.errorMessage = AppError.fileAccessDenied.localizedDescription + return + } + defer { url.stopAccessingSecurityScopedResource() } + + let data: Data + do { + // Read off the main actor so a large file doesn't block the UI. + data = try await Task.detached(priority: .userInitiated) { + try Data(contentsOf: url) + }.value + } catch { + self.errorMessage = String( + format: String(localized: "Movie import failed: %@"), + error.localizedDescription + ) + return + } + + // Transcode on the main actor with the returned `Data`: the bridge call + // computes rom_sha256 internally and locks the core. Movies are small + // (worst case a 16 MiB `.bk2`), so the transcode on main is acceptable + // and keeps the non-Sendable `emulator` off any detached task. + let rnm: Data + do { + switch ext { + case "fm2": rnm = try emulator.movieImportFm2(data) + case "bk2": rnm = try emulator.movieImportBk2(data) + case "fcm": rnm = try emulator.movieImportFcm(data) + case "fmv": rnm = try emulator.movieImportFmv(data) + default: rnm = try emulator.movieImportVmv(data) + } + } catch { + self.errorMessage = String( + format: String(localized: "Movie import failed: %@"), + error.localizedDescription + ) + return + } + + // Save, play, sync. + let name = (self.currentEntry?.name ?? "movie") + "-" + ext + try? self.movies.save(rnm, gameName: name) + do { + try emulator.moviePlay(rnm) + self.syncMovieState() + } catch { + self.errorMessage = String( + format: String(localized: "Movie import failed: %@"), + error.localizedDescription + ) + } + } + } + // MARK: - Input fan-in /// The on-screen pad reports its combined multi-touch mask here; merged with the diff --git a/ios/RustyNES/AudioDepth.swift b/ios/RustyNES/AudioDepth.swift new file mode 100644 index 000000000..f50568a1a --- /dev/null +++ b/ios/RustyNES/AudioDepth.swift @@ -0,0 +1,121 @@ +// +// AudioDepth.swift +// +// The host audio-depth (EQ / pan / reverb / crossfeed) settings model (v1.9.9 +// "Workshop"). These are an output-only DSP stage applied in the CoreAudio sink +// AFTER the core's mono master is drained (see crates/rustynes-ios/src/audio_dsp.rs): +// the deterministic core synthesis is untouched, so save-states / TAS / netplay +// and the accuracy audio oracle are unaffected. Off by default; a disabled or +// flat / centered config is a bit-exact passthrough. +// + +import Combine +import Foundation + +/// A flat config snapshot handed to `EmulatorCore.setAudioDepth` (which marshals it +/// over the C ABI to the cpal sink). `eqDb` is up to 5 band gains (dB); `pan` is up +/// to 6 per-APU-channel positions (-1 left .. +1 right) the Rust stage averages. +struct AudioDepthConfig { + var enabled: Bool + var eqDb: [Float] + var pan: [Float] + var reverbMix: Float + var reverbRoom: Float + var crossfeed: Float +} + +/// Observable, persisted audio-depth settings. AppModel applies them to the running +/// core (on change + on game open). Off by default. +@MainActor +final class AudioDepthModel: ObservableObject { + /// The 5 EQ band center-frequency labels, parallel to `eqDb`. + static let bandLabels = ["60 Hz", "240 Hz", "1 kHz", "3.8 kHz", "12 kHz"] + /// Number of EQ bands (matches `EQ_BAND_COUNT` in audio_dsp.rs). + static let bandCount = 5 + /// Number of per-APU-channel pan slots (matches `PAN_COUNT` in audio_dsp.rs). + static let panCount = 6 + + /// Re-applied to the running core whenever any setting changes. + var onChange: (() -> Void)? + + @Published var enabled: Bool { + didSet { persistBool(enabled, "audioDepthEnabled"); notify() } + } + /// Per-band EQ gains in dB (-12..=12), length `bandCount`. + @Published var eqDb: [Float] { + didSet { persistFloats(eqDb, "audioDepthEq"); notify() } + } + /// Master pan (-1 left .. +1 right), applied to every channel slot. + @Published var pan: Float { + didSet { persistFloat(pan, "audioDepthPan"); notify() } + } + @Published var reverbMix: Float { + didSet { persistFloat(reverbMix, "audioDepthReverbMix"); notify() } + } + @Published var reverbRoom: Float { + didSet { persistFloat(reverbRoom, "audioDepthReverbRoom"); notify() } + } + @Published var crossfeed: Float { + didSet { persistFloat(crossfeed, "audioDepthCrossfeed"); notify() } + } + + init() { + let d = UserDefaults.standard + enabled = d.bool(forKey: "audioDepthEnabled") + eqDb = Self.loadFloats("audioDepthEq", count: Self.bandCount, fallback: 0) + pan = d.object(forKey: "audioDepthPan") == nil ? 0 : d.float(forKey: "audioDepthPan") + reverbMix = d.float(forKey: "audioDepthReverbMix") + reverbRoom = d.object(forKey: "audioDepthReverbRoom") == nil + ? 0.5 : d.float(forKey: "audioDepthReverbRoom") + crossfeed = d.float(forKey: "audioDepthCrossfeed") + } + + /// The flat config to hand to the core. The single master `pan` fills all + /// `panCount` slots (the Rust stage averages them, so this is one master image). + var config: AudioDepthConfig { + AudioDepthConfig( + enabled: enabled, + eqDb: eqDb, + pan: Array(repeating: pan, count: Self.panCount), + reverbMix: reverbMix, + reverbRoom: reverbRoom, + crossfeed: crossfeed + ) + } + + /// Reset every band / stage to its neutral (bypass) value. + func resetToFlat() { + eqDb = Array(repeating: 0, count: Self.bandCount) + pan = 0 + reverbMix = 0 + reverbRoom = 0.5 + crossfeed = 0 + } + + /// Apply the current config to a running core (no-op when nil). + func apply(to core: EmulatorCore?) { + core?.setAudioDepth(config) + } + + private func notify() { onChange?() } + + private func persistBool(_ v: Bool, _ key: String) { + UserDefaults.standard.set(v, forKey: key) + } + + private func persistFloat(_ v: Float, _ key: String) { + UserDefaults.standard.set(v, forKey: key) + } + + private func persistFloats(_ v: [Float], _ key: String) { + UserDefaults.standard.set(v.map { Double($0) }, forKey: key) + } + + private static func loadFloats(_ key: String, count: Int, fallback: Float) -> [Float] { + let stored = (UserDefaults.standard.array(forKey: key) as? [Double])?.map { Float($0) } + ?? [] + var out = Array(repeating: fallback, count: count) + for i in 0.. some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(code.code) + .font(.body.monospaced()) + Text(String(format: "$%04X = $%02X", code.addr, code.data)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + } + .swipeActions { + Button(role: .destructive) { + model.emulator?.cheatRemoveGenie(code.code) + refresh() + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + private func addCode() { + let code = newCode.trimmingCharacters(in: .whitespaces).uppercased() + guard !code.isEmpty else { return } + do { + try model.emulator?.cheatAddGenie(code) + newCode = "" + addError = nil + refresh() + } catch { + addError = error.localizedDescription + } + } + + // MARK: - Raw-RAM editor + + private var rawRamSection: some View { + Section { + HStack { + Text("Address") + Spacer() + Text("$").foregroundStyle(.secondary) + TextField("0000", text: $ramAddrHex) + .font(.body.monospaced()) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .textInputAutocapitalization(.characters) + .frame(width: 80) + .accessibilityLabel(Text("Address")) + .onChange(of: ramAddrHex) { _ in ramPeek = nil } + } + HStack { + Text("Value") + Spacer() + Text("$").foregroundStyle(.secondary) + TextField("00", text: $ramValueHex) + .font(.body.monospaced()) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .textInputAutocapitalization(.characters) + .frame(width: 48) + .accessibilityLabel(Text("Value")) + } + HStack { + Button("Peek", action: peek) + Spacer() + Button("Poke", action: poke) + .buttonStyle(.borderedProminent) + } + .disabled(parsedAddr == nil) + if let ramPeek { + LabeledContent("Current") { + Text(String(format: "$%02X (%d)", ramPeek, ramPeek)) + .font(.body.monospaced()) + } + } + } header: { + Text("Memory editor") + } footer: { + Text("Read or write one byte of CPU RAM ($0000-$1FFF). A poke is one-shot \u{2014} the game may overwrite it next frame.") + } + } + + /// The parsed CPU-RAM address, or nil if it does not parse or is outside the + /// addressable CPU-RAM window the bridge accepts. The bridge clamps writes to + /// `$0000-$1FFF` (2 KiB RAM, mirrored), so an out-of-range value would silently + /// alias into the mirror; reject it here so Peek/Poke stay disabled instead. + private var parsedAddr: UInt16? { + guard let addr = UInt16(ramAddrHex.trimmingCharacters(in: .whitespaces), radix: 16), + addr <= 0x1FFF else { return nil } + return addr + } + private var parsedValue: UInt8? { UInt8(ramValueHex.trimmingCharacters(in: .whitespaces), radix: 16) } + + private func peek() { + guard let addr = parsedAddr else { return } + ramPeek = model.emulator?.peekByte(addr: addr) + } + + private func poke() { + guard let addr = parsedAddr, let value = parsedValue else { return } + model.emulator?.pokeRam(addr: addr, value: value) + ramPeek = model.emulator?.peekByte(addr: addr) + } + + private func refresh() { + codes = model.emulator?.cheatGenieCodes() ?? [] + } +} diff --git a/ios/RustyNES/ContentView.swift b/ios/RustyNES/ContentView.swift index de5872ede..f3ec55234 100644 --- a/ios/RustyNES/ContentView.swift +++ b/ios/RustyNES/ContentView.swift @@ -37,7 +37,7 @@ struct ContentView: View { ) { result in switch result { case .success(let urls): - if let url = urls.first { model.importAndOpen(url) } + if let url = urls.first { Task { await model.importAndOpen(url) } } case .failure(let error): model.errorMessage = error.localizedDescription } @@ -71,12 +71,14 @@ struct ContentView: View { /// The importable UTTypes — ONLY the ROM / archive types, never `.data` /// (`public.data` would let the picker select any file and then fail at load). - /// The custom .nes/.fds/.nsf types are declared in Info.plist - /// (UTImportedTypeDeclarations); resolve them by extension so the picker shows - /// them even before the system fully indexes the declarations. + /// The mobile bridge is iNES/NES 2.0-only (no FDS/NSF load path), so the picker + /// advertises only `.nes` (+ `.zip`) — advertising `.fds`/`.nsf` would let the + /// user pick a file the core cannot load. The custom `.nes` type is declared in + /// Info.plist (UTImportedTypeDeclarations); resolve it by extension so the picker + /// shows it even before the system fully indexes the declarations. private var importableTypes: [UTType] { var types: [UTType] = [.zip] - for ext in ["nes", "fds", "nsf"] { + for ext in ["nes"] { if let t = UTType(filenameExtension: ext) { types.append(t) } } return types @@ -101,7 +103,7 @@ private struct LibraryScreen: View { LazyVGrid(columns: columns, spacing: 16) { ForEach(model.library.entries) { entry in LibraryCell(entry: entry) - .onTapGesture { model.openGame(entry) } + .onTapGesture { Task { await model.openGame(entry) } } // One VoiceOver element per tile (the placeholder art // is decorative), activatable to open the game. .accessibilityElement(children: .combine) diff --git a/ios/RustyNES/DebuggerView.swift b/ios/RustyNES/DebuggerView.swift new file mode 100644 index 000000000..af3ea5994 --- /dev/null +++ b/ios/RustyNES/DebuggerView.swift @@ -0,0 +1,288 @@ +// +// DebuggerView.swift +// +// A read-only debugger inspector (v1.9.9 "Workshop"): the CPU register file, a +// disassembly around the program counter, and a CPU-RAM hex view, all snapshotted +// through the bridge's observational debug API (which never advances or mutates the +// core). A single "Step frame" advances exactly one frame while the inspector holds +// the emulator paused. Optional `.sym` / `.mlb` / `.nl` symbol files annotate the +// disassembly (parsed host-side; see SymbolMap.swift). +// +// Gated OFF the App-Store build via `BuildChannel`: this developer surface is +// reachable only on the FOSS / TestFlight channel (ADR 0027 distribution seam). +// The in-game menu entry and this sheet are both conditional on `BuildChannel.isFoss`. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct DebuggerView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + @State private var cpu: CpuRegs? + @State private var disasm: [DisasmRow] = [] + @State private var memBaseHex: String = "0000" + @State private var memBytes: Data = Data() + @State private var symbols = SymbolMap() + @State private var showingSymImporter = false + + private let disasmCount: UInt32 = 32 + private let memWindow: UInt32 = 256 + + var body: some View { + NavigationStack { + Form { + cpuSection + disasmSection + memorySection + symbolsSection + } + .navigationTitle("Debugger") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + model.emulator?.debugStep() + refresh() + } label: { + Label("Step", systemImage: "forward.frame") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .onAppear(perform: refresh) + .fileImporter( + isPresented: $showingSymImporter, + allowedContentTypes: SymbolTypes.importable, + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result, let url = urls.first { + Task { await loadSymbols(from: url) } + } + } + } + } + + // MARK: - CPU registers + + private var cpuSection: some View { + Section { + if let cpu { + let cols = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + LazyVGrid(columns: cols, alignment: .leading, spacing: 6) { + regCell("A", String(format: "$%02X", cpu.a)) + regCell("X", String(format: "$%02X", cpu.x)) + regCell("Y", String(format: "$%02X", cpu.y)) + regCell("SP", String(format: "$%02X", cpu.s)) + regCell("PC", String(format: "$%04X", cpu.pc)) + regCell("P", String(format: "$%02X", cpu.p)) + } + Text(Self.flagString(cpu.p)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + LabeledContent("Cycles") { + Text("\(cpu.cycles)").font(.caption.monospacedDigit()) + } + if cpu.jammed { + Label("CPU jammed (illegal halt)", systemImage: "exclamationmark.octagon") + .font(.caption).foregroundStyle(.red) + } + } else { + Text("No game running.").foregroundStyle(.secondary) + } + Button { + refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } header: { + Text("CPU") + } footer: { + Text("Read-only. \u{201C}Step\u{201D} advances exactly one frame while paused.") + } + } + + private func regCell(_ name: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text(name).font(.caption.bold()).foregroundStyle(.secondary) + Text(value).font(.body.monospaced()) + } + .accessibilityElement(children: .combine) + } + + /// Decode the P register into an `NV-BDIZC` string (uppercase = set). + private static func flagString(_ p: UInt8) -> String { + let names: [(UInt8, Character)] = [ + (0x80, "N"), (0x40, "V"), (0x20, "-"), (0x10, "B"), + (0x08, "D"), (0x04, "I"), (0x02, "Z"), (0x01, "C"), + ] + return names.map { bit, ch in + (p & bit) != 0 ? String(ch) : String(ch).lowercased() + }.joined(separator: " ") + } + + // MARK: - Disassembly + + private var disasmSection: some View { + Section { + ForEach(disasm, id: \.addr) { row in + disasmRow(row) + } + } header: { + Text("Disassembly") + } + } + + private func disasmRow(_ row: DisasmRow) -> some View { + let isPC = cpu?.pc == row.addr + let hex = row.bytes.map { String(format: "%02X", $0) }.joined(separator: " ") + return HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: isPC ? "arrowtriangle.right.fill" : "arrowtriangle.right") + .font(.caption2) + .foregroundStyle(isPC ? Color.accentColor : Color.clear) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 8) { + Text(String(format: "$%04X", row.addr)) + .font(.caption.monospaced()).foregroundStyle(.secondary) + Text("\(row.mnemonic) \(row.operand)") + .font(.body.monospaced()) + } + if let label = symbols.label(row.addr) { + Text(label).font(.caption2.monospaced()).foregroundStyle(.tint) + } + Text(hex).font(.caption2.monospaced()).foregroundStyle(.secondary.opacity(0.7)) + } + } + .listRowBackground(isPC ? Color.accentColor.opacity(0.12) : nil) + } + + // MARK: - Memory + + private var memorySection: some View { + Section { + HStack { + Text("Base $") + TextField("0000", text: $memBaseHex) + .font(.body.monospaced()) + .autocorrectionDisabled() + .textInputAutocapitalization(.characters) + .accessibilityLabel(Text("Base address")) + .onSubmit(refresh) + Spacer() + Button("Read", action: refresh) + } + ForEach(memoryRows(), id: \.addr) { row in + Text(row.text) + .font(.caption2.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } header: { + Text("Memory") + } footer: { + Text(String( + format: String(localized: "%d bytes from the base address, side-effect-free."), + Int(memWindow) + )) + } + } + + private struct MemRow: Identifiable { + let addr: UInt16 + let text: String + var id: UInt16 { addr } + } + + private func memoryRows() -> [MemRow] { + guard let base = UInt16(memBaseHex.trimmingCharacters(in: .whitespaces), radix: 16) else { + return [] + } + var rows: [MemRow] = [] + let bytes = [UInt8](memBytes) + var offset = 0 + while offset < bytes.count { + let rowAddr = base &+ UInt16(offset & 0xFFFF) + let slice = bytes[offset.. String? in + guard let data = try? Data(contentsOf: url) else { return nil } + return String(data: data, encoding: .utf8) + }.value + guard let text else { return } + var map = symbols + map.merge(text, format: format) + symbols = map + refresh() + } + + // MARK: - Refresh + + private func refresh() { + guard let emulator = model.emulator else { + cpu = nil + disasm = [] + memBytes = Data() + return + } + let regs = emulator.debugCpuState() + cpu = regs + disasm = emulator.debugDisassemble(pc: regs.pc, count: disasmCount) + if let base = UInt16(memBaseHex.trimmingCharacters(in: .whitespaces), radix: 16) { + memBytes = emulator.debugReadMemory(start: base, len: memWindow) + } + } +} + +/// The symbol-file UTTypes for import. `.sym` / `.mlb` / `.nl` are plain text; resolve +/// each by extension so the picker shows them, falling back to plain text / data. +enum SymbolTypes { + static var importable: [UTType] { + let exts = ["sym", "mlb", "nl"] + let types = exts.compactMap { UTType(filenameExtension: $0) } + return types.isEmpty ? [.plainText, .data] : types + [.plainText] + } +} diff --git a/ios/RustyNES/EmulatorCore.swift b/ios/RustyNES/EmulatorCore.swift index 62c4b6352..b86788c05 100644 --- a/ios/RustyNES/EmulatorCore.swift +++ b/ios/RustyNES/EmulatorCore.swift @@ -203,10 +203,18 @@ final class EmulatorCore { return } + // TAStudio (v1.9.9): when a scripted playback is active, inject this + // frame's P1 mask before advancing. No-op otherwise (live input path). + tasAdvanceIfActive() + // Run a frame and hand the RGBA framebuffer straight to wgpu (which // presents). UniFFI marshals `run_frame()` as a Swift `Data`. let frame = controller.runFrame() + // TAStudio export (v1.9.9): if this frame exhausted the authored table, + // stop the recorder NOW (before any idle frames are recorded). + tasFinalizeExportIfPending() + // HD-pack path (v1.9.5): when a pack is loaded, present the composited HD // frame (which can exceed 256x240) instead of the stock framebuffer. The // pack supplies the final look, so no on-screen filter is layered on top. @@ -575,6 +583,211 @@ final class EmulatorCore { /// A status snapshot for the netplay panel/HUD. func npStatus() -> NpStatus { controller.npStatus() } + // MARK: - Cheats (v1.9.9 "Workshop") + + /// Add a Game Genie code (6 or 8 characters). The core applies it on every + /// PRG read until removed. + /// - Throws: `MobileError.cheat` if the code is malformed. + func cheatAddGenie(_ code: String) throws { try controller.cheatAddGenie(code: code) } + /// Remove a Game Genie code (no-op if not present). + func cheatRemoveGenie(_ code: String) { controller.cheatRemoveGenie(code: code) } + /// Remove every active Game Genie code. + func cheatClearGenie() { controller.cheatClearGenie() } + /// The currently-active Game Genie codes. + func cheatGenieCodes() -> [GenieCodeInfo] { controller.cheatGenieCodes() } + /// Write one byte into CPU RAM ($0000..=$1FFF) via the core's `poke_ram`. + func pokeRam(addr: UInt16, value: UInt8) { controller.pokeRam(addr: addr, value: value) } + /// Read one byte from the CPU bus, side-effect-free. + func peekByte(addr: UInt16) -> UInt8 { controller.peekByte(addr: addr) } + + // MARK: - Read-only debugger inspector (v1.9.9 "Workshop") + + /// A read-only snapshot of the CPU registers (does not advance the core). + func debugCpuState() -> CpuRegs { controller.debugCpuState() } + /// Read `len` bytes from the CPU bus starting at `start` (side-effect-free). + func debugReadMemory(start: UInt16, len: UInt32) -> Data { + controller.debugReadMemory(start: start, len: len) + } + /// Disassemble `count` instructions starting at `pc`. + func debugDisassemble(pc: UInt16, count: UInt32) -> [DisasmRow] { + controller.debugDisassemble(pc: pc, count: count) + } + + /// Advance one frame WITHOUT presenting, for the debugger's single-step while + /// the emulator is paused behind the inspector sheet. Drains + discards the + /// frame's audio so a paused step doesn't leak sound. Only meaningful while + /// paused (no display-link tick); calling it while running double-advances. + func debugStep() { + controller.stepFrame() + _ = controller.drainAudio() + } + + // MARK: - Foreign movie import (v1.9.9 "Workshop") + // + // Each importer transcodes a foreign movie into native `.rnm` bytes stamped + // with THIS game's ROM hash; the caller plays them via `moviePlay` and/or + // saves them as a `.rnm`. + + /// Import an FCEUX `.fm2` movie and return native `.rnm` bytes. + /// - Throws: `MobileError.movie` if the bytes are not a parseable `.fm2`. + func movieImportFm2(_ data: Data) throws -> Data { try controller.movieImportFm2(bytes: data) } + /// Import a BizHawk `.bk2` movie and return native `.rnm` bytes. + /// - Throws: `MobileError.movie` if the archive / input log is malformed. + func movieImportBk2(_ data: Data) throws -> Data { try controller.movieImportBk2(bytes: data) } + /// Import a Nestopia `.fcm` movie and return native `.rnm` bytes. + /// - Throws: `MobileError.movie` if the bytes are not a parseable `.fcm`. + func movieImportFcm(_ data: Data) throws -> Data { try controller.movieImportFcm(bytes: data) } + /// Import a Famtasia `.fmv` movie and return native `.rnm` bytes. + /// - Throws: `MobileError.movie` if the bytes are not a parseable `.fmv`. + func movieImportFmv(_ data: Data) throws -> Data { try controller.movieImportFmv(bytes: data) } + /// Import a VirtuaNES `.vmv` movie and return native `.rnm` bytes. + /// - Throws: `MobileError.movie` if the bytes are not a parseable `.vmv`. + func movieImportVmv(_ data: Data) throws -> Data { try controller.movieImportVmv(bytes: data) } + + // MARK: - Audio-depth DSP (v1.9.9 "Workshop") + + /// Publish the live host audio-depth (EQ / pan / reverb / crossfeed) config to + /// the CoreAudio sink. Off / flat = bit-exact passthrough. No-op if the sink + /// failed to open. + func setAudioDepth(_ config: AudioDepthConfig) { + guard let audio else { return } + config.eqDb.withUnsafeBufferPointer { eqBuf in + config.pan.withUnsafeBufferPointer { panBuf in + rustynes_ios_audio_set_depth( + audio, + config.enabled ? 1 : 0, + eqBuf.baseAddress, eqBuf.count, + panBuf.baseAddress, panBuf.count, + config.reverbMix, config.reverbRoom, config.crossfeed + ) + } + } + } + + // MARK: - TAStudio scripted input (v1.9.9 "Workshop") + // + // A pragmatic touch piano-roll authors a host-side table of per-frame P1 + // masks. Playback injects one mask per frame through the EXISTING bridge + // (`setButtons` + `runFrame`), so it is deterministic; with a recording armed + // (`movieRecordFromPowerOn`) the core's recorder captures the same input, + // yielding a real `.rnm`. Off by default — the normal input path is untouched + // unless a playback is active. + + /// Per-frame P1 masks to inject, the read cursor, and the active flag. Written + /// on the main thread (`tasStartPlayback`/`tasStop`) and read on the + /// CADisplayLink thread (`tasAdvanceLocked`), so all access goes through + /// `frameLock`. + private var _tasFrames: [UInt8] = [] + private var _tasCursor = 0 + private var _tasActive = false + /// While true, the active playback is being captured to a `.rnm`; the recorder + /// is stopped at the exact frame the authored table is exhausted (no trailing + /// idle frames). Guarded by `frameLock`. + private var _tasExporting = false + /// Set on the emu thread when the last authored frame has just been injected, so + /// `tick()` stops the recorder immediately after running that frame. + private var _tasFinalizeExport = false + /// The finished export bytes, captured on the emu thread and taken by the host. + private var _tasExportedMovie: Data? + + /// Whether a scripted TAStudio playback is currently running. + var tasIsActive: Bool { + frameLock.lock() + defer { frameLock.unlock() } + return _tasActive + } + + /// Begin injecting `p1Masks` one-per-frame (from the current state). Pair with + /// `movieRecordFromPowerOn()` first to capture the run as a `.rnm`. + func tasStartPlayback(p1Masks: [UInt8]) { + frameLock.lock() + _tasFrames = p1Masks + _tasCursor = 0 + _tasActive = !p1Masks.isEmpty + _tasExporting = false + frameLock.unlock() + } + + /// Arm a power-on recording AND begin scripted playback of `p1Masks` as a single + /// export: the recorder is stopped at the exact last authored frame (rather than + /// on the next host poll tick), so the saved `.rnm` carries no trailing idle + /// frames. The finished bytes are retrieved via `tasTakeExportedMovie()`. + func tasStartExport(p1Masks: [UInt8]) { + // An empty table would arm the recorder with no frames to inject: playback + // never activates, so the finalize path never fires and the recorder is left + // armed. Refuse to start instead, so it is never left in that state. + guard !p1Masks.isEmpty else { return } + controller.movieRecordFromPowerOn() + frameLock.lock() + _tasFrames = p1Masks + _tasCursor = 0 + _tasActive = !p1Masks.isEmpty + _tasExporting = !p1Masks.isEmpty + _tasFinalizeExport = false + _tasExportedMovie = nil + frameLock.unlock() + } + + /// Take (and clear) the finished export movie bytes, or nil if an export is + /// still running / none is pending. Polled by the host after `tasStartExport`. + func tasTakeExportedMovie() -> Data? { + frameLock.lock() + defer { frameLock.unlock() } + let bytes = _tasExportedMovie + _tasExportedMovie = nil + return bytes + } + + /// Stop scripted playback (returns to live input). If an export was in flight, + /// finalize it on the next tick so the partial recording is still captured. + func tasStop() { + frameLock.lock() + let wasExporting = _tasExporting && _tasActive + _tasActive = false + if wasExporting { _tasFinalizeExport = true } + frameLock.unlock() + } + + /// If a scripted playback is active, take the next P1 mask (advancing the + /// cursor, deactivating at the end) and feed it to port 0 before the frame. + /// Snapshots under `frameLock`, then calls `setButtons` OUTSIDE the lock + /// (`setButtons` re-locks; NSLock is not reentrant). + private func tasAdvanceIfActive() { + frameLock.lock() + guard _tasActive else { + frameLock.unlock() + return + } + let mask = _tasCursor < _tasFrames.count ? _tasFrames[_tasCursor] : 0 + _tasCursor += 1 + if _tasCursor >= _tasFrames.count { + _tasActive = false + // This is the last authored frame; finalize the export right after the + // frame is run (in `tick`) so no idle frames are recorded past it. + if _tasExporting { _tasFinalizeExport = true } + } + frameLock.unlock() + setButtons(port: 0, mask: mask) + } + + /// Stop the export recorder immediately if the authored table was just + /// exhausted (called from `tick` right after the last authored frame ran). + private func tasFinalizeExportIfPending() { + frameLock.lock() + guard _tasFinalizeExport else { + frameLock.unlock() + return + } + _tasFinalizeExport = false + _tasExporting = false + frameLock.unlock() + // `movieStopRecording` re-locks the bridge; call it outside `frameLock`. + let bytes = controller.movieStopRecording() + frameLock.lock() + _tasExportedMovie = bytes + frameLock.unlock() + } + // MARK: - Teardown /// Drop the renderer + audio sink and stop the loop. The `NesController` is diff --git a/ios/RustyNES/GameView.swift b/ios/RustyNES/GameView.swift index 8737f0526..7a2d1c7b5 100644 --- a/ios/RustyNES/GameView.swift +++ b/ios/RustyNES/GameView.swift @@ -17,6 +17,10 @@ struct GameView: View { @State private var showingNetplay = false @State private var showingAchievements = false @State private var showingLua = false + // Creator / power tools (v1.9.9 "Workshop"). + @State private var showingCheats = false + @State private var showingTAStudio = false + @State private var showingDebugger = false // The top bar + pill menu visibility, toggled by the on-screen MENU pill (mirrors // the Android MENU pill toggling `controlsVisible`). @State private var menuVisible = true @@ -72,6 +76,9 @@ struct GameView: View { onNetplay: { showingNetplay = true }, onAchievements: { showingAchievements = true }, onLua: { showingLua = true }, + onCheats: { showingCheats = true }, + onTAStudio: { showingTAStudio = true }, + onDebugger: { showingDebugger = true }, onRecord: { model.recorder.toggle() }, onSettings: { showingSettings = true }, onReset: { model.emulator?.reset() }, @@ -121,15 +128,33 @@ struct GameView: View { .sheet(isPresented: $showingLua) { LuaConsoleView() } + .sheet(isPresented: $showingCheats) { + CheatsView() + } + .sheet(isPresented: $showingTAStudio) { + TAStudioView() + } + .sheet(isPresented: $showingDebugger) { + DebuggerView() + } // Pause the emulator while a menu/sheet is open so the player doesn't lose // progress or hear audio behind it; resume once all are dismissed. The TAS / - // Movies panel is the exception: recording / playback must keep the core - // running, so it does NOT pause emulation. - .onChange(of: showingStates) { _ in model.setMenuPaused(showingStates || showingSettings) } - .onChange(of: showingSettings) { _ in model.setMenuPaused(showingStates || showingSettings) } + // Movies + Cheats panels are exceptions: recording / playback / live cheats + // must keep the core running, so they do NOT pause emulation. The read-only + // debugger DOES pause (its "Step" button advances exactly one frame). + .onChange(of: showingStates) { _ in updateMenuPaused() } + .onChange(of: showingSettings) { _ in updateMenuPaused() } + .onChange(of: showingDebugger) { _ in updateMenuPaused() } .statusBarHidden(true) } + /// Recompute whether a modal that must pause emulation is open (Save States, + /// Settings, or the read-only Debugger). Cheats / TAStudio / Movies keep the + /// core running by design. + private func updateMenuPaused() { + model.setMenuPaused(showingStates || showingSettings || showingDebugger) + } + private var topBar: some View { HStack(spacing: 16) { Button { model.closeGame() } label: { diff --git a/ios/RustyNES/HDPackStore.swift b/ios/RustyNES/HDPackStore.swift index a73d3c946..5ab87fc7f 100644 --- a/ios/RustyNES/HDPackStore.swift +++ b/ios/RustyNES/HDPackStore.swift @@ -53,9 +53,12 @@ final class HDPackStore: ObservableObject { let id = url.deletingPathExtension().lastPathComponent let dest = self.url(for: id) ensureDirectory() + // Acquire the security scope on the calling actor so it brackets the await + // (acquiring it inside the detached task can fail — the scope is tied to the + // context that received the picked URL). + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } try await Task.detached(priority: .userInitiated) { - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) try data.write(to: dest, options: .atomic) }.value diff --git a/ios/RustyNES/Info.plist b/ios/RustyNES/Info.plist index 90afc6d9e..4b3a137c0 100644 --- a/ios/RustyNES/Info.plist +++ b/ios/RustyNES/Info.plist @@ -67,7 +67,9 @@ declaring background audio would only invite App Review rejection. --> + format is declared Owner; .zip is an Alternate handler. The mobile bridge + is iNES/NES 2.0-only, so .fds/.nsf are intentionally NOT declared (they + would route unloadable files to the app). --> CFBundleDocumentTypes @@ -80,26 +82,6 @@ com.doublegate.rustynes.nes - - CFBundleTypeName - Famicom Disk System Image - LSHandlerRank - Owner - LSItemContentTypes - - com.doublegate.rustynes.fds - - - - CFBundleTypeName - NES Sound File - LSHandlerRank - Owner - LSItemContentTypes - - com.doublegate.rustynes.nsf - - CFBundleTypeName ZIP Archive @@ -134,8 +116,9 @@ - + UTImportedTypeDeclarations @@ -155,40 +138,6 @@ - - UTTypeIdentifier - com.doublegate.rustynes.fds - UTTypeDescription - Famicom Disk System Image - UTTypeConformsTo - - public.data - - UTTypeTagSpecification - - public.filename-extension - - fds - - - - - UTTypeIdentifier - com.doublegate.rustynes.nsf - UTTypeDescription - NES Sound File - UTTypeConformsTo - - public.data - - UTTypeTagSpecification - - public.filename-extension - - nsf - - - diff --git a/ios/RustyNES/Localizable.xcstrings b/ios/RustyNES/Localizable.xcstrings index e0279c4b1..fcb7a4ba5 100644 --- a/ios/RustyNES/Localizable.xcstrings +++ b/ios/RustyNES/Localizable.xcstrings @@ -2,6 +2,351 @@ "sourceLanguage" : "en", "version" : "1.0", "strings" : { + "Could not access the selected file." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "No se pudo acceder al archivo seleccionado." } } + } + }, + "Frame" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuadro" } } + } + }, + "Pan" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Balance" } } + } + }, + "Reverb mix" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mezcla de reverberación" } } + } + }, + "Reverb room" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Tamaño de sala" } } + } + }, + "Crossfeed" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mezcla de canales" } } + } + }, + "Scanline intensity" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Intensidad de líneas de escaneo" } } + } + }, + "Aperture mask" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Máscara de apertura" } } + } + }, + "Saturation" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Saturación" } } + } + }, + "Sharpness" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Nitidez" } } + } + }, + "Tint" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Tono" } } + } + }, + "Phase" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Fase" } } + } + }, + "Base address" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Dirección base" } } + } + }, + "Saving restarts the game to record from power-on." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Guardar reinicia el juego para grabar desde el encendido." } } + } + }, + "Cheats" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Trucos" } } + } + }, + "Add Game Genie codes or edit memory" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Añade códigos Game Genie o edita la memoria" } } + } + }, + "TAStudio" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "TAStudio" } } + } + }, + "Author a frame-by-frame input movie" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Crea una película de entrada cuadro a cuadro" } } + } + }, + "Debugger" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Depurador" } } + } + }, + "Inspect CPU, memory, and disassembly" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Inspecciona la CPU, la memoria y el desensamblado" } } + } + }, + "Add" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Añadir" } } + } + }, + "No active codes." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "No hay códigos activos." } } + } + }, + "Clear all codes" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Borrar todos los códigos" } } + } + }, + "Game Genie" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Game Genie" } } + } + }, + "6- or 8-character codes are applied live to PRG reads. Enter codes for the game you own." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Los códigos de 6 u 8 caracteres se aplican en vivo a las lecturas de PRG. Introduce códigos del juego que posees." } } + } + }, + "Address" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Dirección" } } + } + }, + "Value" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Valor" } } + } + }, + "Peek" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Leer" } } + } + }, + "Poke" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Escribir" } } + } + }, + "Current" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Actual" } } + } + }, + "Memory editor" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Editor de memoria" } } + } + }, + "Read or write one byte of CPU RAM ($0000-$1FFF). A poke is one-shot — the game may overwrite it next frame." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Lee o escribe un byte de la RAM de la CPU ($0000-$1FFF). La escritura es única: el juego puede sobrescribirla en el siguiente cuadro." } } + } + }, + "Step" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Paso" } } + } + }, + "No game running." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Ningún juego en ejecución." } } + } + }, + "Cycles" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Ciclos" } } + } + }, + "CPU jammed (illegal halt)" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "CPU bloqueada (parada ilegal)" } } + } + }, + "Refresh" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Actualizar" } } + } + }, + "CPU" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "CPU" } } + } + }, + "Read-only. “Step” advances exactly one frame while paused." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Solo lectura. «Paso» avanza exactamente un cuadro mientras está en pausa." } } + } + }, + "Disassembly" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Desensamblado" } } + } + }, + "Base $" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Base $" } } + } + }, + "Read" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Leer" } } + } + }, + "Memory" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Memoria" } } + } + }, + "%d bytes from the base address, side-effect-free." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "%d bytes desde la dirección base, sin efectos secundarios." } } + } + }, + "Load symbol file (.sym / .mlb / .nl)" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cargar archivo de símbolos (.sym / .mlb / .nl)" } } + } + }, + "Loaded labels" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Etiquetas cargadas" } } + } + }, + "Clear symbols" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Borrar símbolos" } } + } + }, + "Symbols" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Símbolos" } } + } + }, + "Annotates the disassembly with labels. Parsed on-device; read-only." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Anota el desensamblado con etiquetas. Se analiza en el dispositivo; solo lectura." } } + } + }, + "Enable audio depth" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Activar profundidad de audio" } } + } + }, + "Reset to flat" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Restablecer a plano" } } + } + }, + "Audio depth" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Profundidad de audio" } } + } + }, + "Output-only EQ, stereo pan, reverb, and headphone crossfeed. Off by default; flat = unchanged sound." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Ecualizador, paneo estéreo, reverberación y crossfeed para auriculares, solo en la salida. Desactivado por defecto; plano = sonido sin cambios." } } + } + }, + "Import a foreign movie" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Importar una película externa" } } + } + }, + "Import" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Importar" } } + } + }, + "Convert an FCEUX .fm2, BizHawk .bk2, Nestopia .fcm, Famtasia .fmv, or VirtuaNES .vmv into a native .rnm for this game." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Convierte un .fm2 de FCEUX, .bk2 de BizHawk, .fcm de Nestopia, .fmv de Famtasia o .vmv de VirtuaNES en un .rnm nativo para este juego." } } + } + }, + "Could not read movie file: %@" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "No se pudo leer el archivo de película: %@" } } + } + }, + "Movie import failed: %@" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Falló la importación de la película: %@" } } + } + }, + "Unsupported movie format: %@" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Formato de película no compatible: %@" } } + } + }, + "Recording the playback to a movie…" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Grabando la reproducción en una película…" } } + } + }, + "Add 30" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Añadir 30" } } + } + }, + "Clear" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Borrar" } } + } + }, + "Play" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Reproducir" } } + } + }, + "Stop" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Detener" } } + } + }, + "Save .rnm" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Guardar .rnm" } } + } + }, + "Frame %lld, %@" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuadro %lld, %@" } } + } + }, + "%lld frames" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "%lld cuadros" } } + } + }, + "Off" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Desactivado" } } + } + }, + "Code (e.g. GOSSIP)" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Código (p. ej. GOSSIP)" } } + } + }, "A cycle-accurate NES emulator for iPhone and iPad. RustyNES plays only the ROM files you supply from your own device. No games are bundled or downloaded — you must own the games you play." : { "localizations" : { "es" : { "stringUnit" : { "state" : "translated", "value" : "Un emulador de NES de precisión de ciclo para iPhone y iPad. RustyNES solo reproduce los archivos ROM que tú proporcionas desde tu propio dispositivo. No se incluyen ni descargan juegos: debes ser propietario de los juegos que utilizas." } } diff --git a/ios/RustyNES/MoviePanelView.swift b/ios/RustyNES/MoviePanelView.swift index 0bd05f434..ef781e8db 100644 --- a/ios/RustyNES/MoviePanelView.swift +++ b/ios/RustyNES/MoviePanelView.swift @@ -16,6 +16,7 @@ struct MoviePanelView: View { @Environment(\.dismiss) private var dismiss @State private var showingImporter = false + @State private var showingForeignImporter = false @State private var shareItem: ShareItem? var body: some View { @@ -70,6 +71,22 @@ struct MoviePanelView: View { Text("Play") } + // Foreign movie import (v1.9.9): transcode an FCEUX / BizHawk / + // Nestopia / Famtasia / VirtuaNES movie into a native `.rnm`, + // save it, and play it on the running game. + Section { + Button { + showingForeignImporter = true + } label: { + Label("Import a foreign movie", systemImage: "arrow.down.doc") + } + .disabled(model.emulator == nil) + } header: { + Text("Import") + } footer: { + Text("Convert an FCEUX .fm2, BizHawk .bk2, Nestopia .fcm, Famtasia .fmv, or VirtuaNES .vmv into a native .rnm for this game.") + } + SavedMoviesSection(manager: model.movies, shareItem: $shareItem) } .navigationTitle("TAS / Movies") @@ -87,6 +104,15 @@ struct MoviePanelView: View { model.playMovie(at: url) } } + .fileImporter( + isPresented: $showingForeignImporter, + allowedContentTypes: MovieTypes.foreign, + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result, let url = urls.first { + model.importForeignMovie(at: url) + } + } .sheet(item: $shareItem) { item in ShareSheet(items: [item.url]) } @@ -174,6 +200,15 @@ enum MovieTypes { if let t = UTType(filenameExtension: "rnm") { return [t] } return [.data] } + + /// The foreign movie formats the v1.9.9 importer accepts. Resolved by + /// extension (none are system-known), falling back to plain data so the + /// picker still shows arbitrary files. + static var foreign: [UTType] { + let exts = ["fm2", "bk2", "fcm", "fmv", "vmv"] + let types = exts.compactMap { UTType(filenameExtension: $0) } + return types.isEmpty ? [.data] : types + } } /// An Identifiable wrapper so a file URL can drive `.sheet(item:)` without adding a diff --git a/ios/RustyNES/PaletteManager.swift b/ios/RustyNES/PaletteManager.swift index 2b284a497..5997606be 100644 --- a/ios/RustyNES/PaletteManager.swift +++ b/ios/RustyNES/PaletteManager.swift @@ -46,14 +46,23 @@ final class PaletteManager: ObservableObject { /// bytes into the sandbox keyed by the file's name stem and returns the id. /// - Throws: if the URL cannot be read or the bytes cannot be written. @discardableResult - func importPalette(from url: URL) throws -> String { - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - - let data = try Data(contentsOf: url) + func importPalette(from url: URL) async throws -> String { let id = url.deletingPathExtension().lastPathComponent + let dest = self.url(for: id) ensureDirectory() - try data.write(to: self.url(for: id), options: .atomic) + // Acquire the security scope on the main actor and hold it across the off-main + // read (security-scoped access is actor-bound, so it must NOT be started + // inside the detached task). The detached closure captures only `url` / `dest`. + guard url.startAccessingSecurityScopedResource() else { + throw AppError.fileAccessDenied + } + defer { url.stopAccessingSecurityScopedResource() } + // The read + write run off the main actor for consistency with the other + // importers; the `palettes` list refreshes back on the main actor. + try await Task.detached(priority: .userInitiated) { + let data = try Data(contentsOf: url) + try data.write(to: dest, options: .atomic) + }.value reload() return id } diff --git a/ios/RustyNES/PillMenu.swift b/ios/RustyNES/PillMenu.swift index 710cde911..ea217d57b 100644 --- a/ios/RustyNES/PillMenu.swift +++ b/ios/RustyNES/PillMenu.swift @@ -23,6 +23,10 @@ struct PillMenu: View { var onNetplay: () -> Void var onAchievements: () -> Void var onLua: () -> Void + // Creator / power tools (v1.9.9 "Workshop"). + var onCheats: () -> Void + var onTAStudio: () -> Void + var onDebugger: () -> Void var onRecord: () -> Void var onSettings: () -> Void var onReset: () -> Void @@ -42,6 +46,17 @@ struct PillMenu: View { hint: "View RetroAchievements progress", action: onAchievements) pillButton("curlybraces", "Lua console", hint: "Run a script against the game", action: onLua) + // Creator / power tools (v1.9.9 "Workshop"). + pillButton("wand.and.stars", "Cheats", + hint: "Add Game Genie codes or edit memory", action: onCheats) + pillButton("pianokeys", "TAStudio", + hint: "Author a frame-by-frame input movie", action: onTAStudio) + // The debugger is a developer surface gated OFF the App-Store build + // (ADR 0027): it appears only on the FOSS / TestFlight channel. + if BuildChannel.isFoss { + pillButton("ladybug", "Debugger", + hint: "Inspect CPU, memory, and disassembly", action: onDebugger) + } // Record-screen pill: a red dot + "Stop recording" while active. pillButton( recorder.isRecording ? "stop.circle" : "record.circle", diff --git a/ios/RustyNES/ROMLibrary.swift b/ios/RustyNES/ROMLibrary.swift index 9b0c81739..16c2c593b 100644 --- a/ios/RustyNES/ROMLibrary.swift +++ b/ios/RustyNES/ROMLibrary.swift @@ -78,20 +78,32 @@ final class ROMLibrary: ObservableObject { /// - Throws: if the URL cannot be read or the bytes cannot be written to the /// sandbox. @discardableResult - func importROM(from url: URL) throws -> LibraryEntry { - // Files-app URLs are security-scoped; bracket the read. - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - - let data = try Data(contentsOf: url) - let sha = RomIdentity.sha256Hex(data) - let dest = romURL(for: sha) - - // Copy into the sandbox (idempotent: a re-import of the same ROM just - // updates the index entry's last-played, not the bytes). - if !fileManager.fileExists(atPath: dest.path) { - try data.write(to: dest, options: .atomic) + func importROM(from url: URL) async throws -> LibraryEntry { + // The (potentially large) read + hash + write run off the main actor so + // the UI doesn't block; the index mutation hops back to the actor. Mirror + // HDPackStore.importPack: capture the main-actor-derived destination dir + // as a value before the detached task. + let romsDir = self.romsDir + // Files-app URLs are security-scoped. Acquire the scope on the main actor and + // hold it across the off-main read (security-scoped access is actor-bound, so + // it must NOT be started inside the detached task). The detached closure + // captures only `url` / `romsDir` — both Sendable. + guard url.startAccessingSecurityScopedResource() else { + throw AppError.fileAccessDenied } + defer { url.stopAccessingSecurityScopedResource() } + let sha = try await Task.detached(priority: .userInitiated) { + let data = try Data(contentsOf: url) + let sha = RomIdentity.sha256Hex(data) + let dest = romsDir.appendingPathComponent("\(sha).nes") + + // Copy into the sandbox (idempotent: a re-import of the same ROM just + // updates the index entry's last-played, not the bytes). + if !FileManager.default.fileExists(atPath: dest.path) { + try data.write(to: dest, options: .atomic) + } + return sha + }.value let displayName = url.deletingPathExtension().lastPathComponent if let idx = entries.firstIndex(where: { $0.sha == sha }) { @@ -112,9 +124,13 @@ final class ROMLibrary: ObservableObject { return entry } - /// Load a library entry's ROM bytes from the sandbox. - func romData(for entry: LibraryEntry) throws -> Data { - try Data(contentsOf: romURL(for: entry.sha)) + /// Load a library entry's ROM bytes from the sandbox. The read runs off the + /// main actor so a large ROM doesn't block the UI. + func romData(for entry: LibraryEntry) async throws -> Data { + let url = romURL(for: entry.sha) + return try await Task.detached(priority: .userInitiated) { + try Data(contentsOf: url) + }.value } // MARK: - Mutations diff --git a/ios/RustyNES/RustyNESApp.swift b/ios/RustyNES/RustyNESApp.swift index 0c9716c80..d1866f56b 100644 --- a/ios/RustyNES/RustyNESApp.swift +++ b/ios/RustyNES/RustyNESApp.swift @@ -21,10 +21,10 @@ struct RustyNESApp: App { ContentView() .environmentObject(model) .environmentObject(entitlements) - // Open a ROM dragged onto the app / shared from Files (.nes/.fds/ - // .nsf are registered in Info.plist). + // Open a ROM dragged onto the app / shared from Files (.nes/.zip + // are registered in Info.plist; the bridge is iNES/NES 2.0-only). .onOpenURL { url in - model.importAndOpen(url) + Task { await model.importAndOpen(url) } } // `onAppear` runs on the main actor with a synchronous closure, so // it can call the `@MainActor` `refresh()` directly (no `await` diff --git a/ios/RustyNES/SettingsView.swift b/ios/RustyNES/SettingsView.swift index 8c16d4c88..f85ba31b1 100644 --- a/ios/RustyNES/SettingsView.swift +++ b/ios/RustyNES/SettingsView.swift @@ -49,6 +49,9 @@ struct SettingsView: View { Text("Silence the emulator without pausing it.") } + // Host audio-depth DSP (v1.9.9): EQ / pan / reverb / crossfeed. + AudioDepthSection(depth: model.audioDepth) + Section { Toggle("Haptic feedback", isOn: $model.hapticsEnabled) .disabled(!model.hapticsSupported) @@ -187,11 +190,14 @@ private struct FilterParamSliders: View { /// A labelled `Slider` with a live numeric readout, for one shader parameter. private struct ParamSlider: View { - let title: String + // `LocalizedStringKey` so `Text(title)` uses the localizing initializer: the + // shader-param + audio-depth labels resolve through the String Catalog. + // Unit-style band labels ("60 Hz") have no catalog entry and render verbatim. + let title: LocalizedStringKey @Binding var value: Float let range: ClosedRange - init(_ title: String, value: Binding, range: ClosedRange) { + init(_ title: LocalizedStringKey, value: Binding, range: ClosedRange) { self.title = title self._value = value self.range = range @@ -211,6 +217,49 @@ private struct ParamSlider: View { } } +// MARK: - Audio depth (v1.9.9) + +/// The host audio-depth controls: a master enable, a 5-band EQ, a stereo pan, a +/// Schroeder reverb, and a headphone crossfeed. All output-only (post-mix), so a +/// flat / disabled config is a bit-exact passthrough. Observes the model so the +/// per-band sliders bind live and re-apply to the running core. +private struct AudioDepthSection: View { + @ObservedObject var depth: AudioDepthModel + + var body: some View { + Section { + Toggle("Enable audio depth", isOn: $depth.enabled) + if depth.enabled { + ForEach(0.. Binding { + Binding( + get: { i < depth.eqDb.count ? depth.eqDb[i] : 0 }, + set: { var copy = depth.eqDb; if i < copy.count { copy[i] = $0; depth.eqDb = copy } } + ) + } +} + // MARK: - Controllers /// Lists connected hardware controllers, lets the user pick which NES port each @@ -499,9 +548,12 @@ struct PalettePickerSection: View { allowedContentTypes: PaletteTypes.importable, allowsMultipleSelection: false ) { result in - if case .success(let urls) = result, let url = urls.first, - let id = try? manager.importPalette(from: url) { - selectedId = id + if case .success(let urls) = result, let url = urls.first { + Task { + if let id = try? await manager.importPalette(from: url) { + selectedId = id + } + } } } } diff --git a/ios/RustyNES/SymbolMap.swift b/ios/RustyNES/SymbolMap.swift new file mode 100644 index 000000000..49c05b131 --- /dev/null +++ b/ios/RustyNES/SymbolMap.swift @@ -0,0 +1,109 @@ +// +// SymbolMap.swift +// +// A host-side symbol-file parser (v1.9.9 "Workshop") for the debugger inspector. +// RustyNES's core exposes no symbol API (the desktop parser lives in the frontend +// crate, which the mobile bridge does not depend on), so the iOS debugger parses +// the common label formats here in Swift and overlays the labels on the +// bridge-provided disassembly. Read-only; purely a naming aid. +// +// Supported formats (auto-detected by file extension): +// .sym ca65 / WLA-DX "ADDR LABEL" (ADDR optionally "bank:addr" / "$addr") +// .mlb Mesen label file "MemoryType:Address:Label[:Comment]" +// .nl FCEUX name list "$ADDR#Name#Comment" +// +// All parsing is bounds-checked and total: a malformed line is skipped, never a +// crash, and the label count is capped so a hostile file cannot exhaust memory. +// + +import Foundation + +/// The symbol-file formats the debugger can load. +enum SymbolFormat { + case sym, mlb, nl + + /// Resolve a format from a file extension, or nil if unsupported. + static func from(extension ext: String) -> SymbolFormat? { + switch ext.lowercased() { + case "sym": return .sym + case "mlb": return .mlb + case "nl": return .nl + default: return nil + } + } +} + +/// An address -> label map parsed from a symbol file. +struct SymbolMap { + /// Hard cap on parsed labels so a hostile / huge file cannot exhaust memory. + private static let maxLabels = 200_000 + + private(set) var labels: [UInt16: String] = [:] + + var count: Int { labels.count } + var isEmpty: Bool { labels.isEmpty } + + /// The label at `addr`, if any. + func label(_ addr: UInt16) -> String? { labels[addr] } + + /// Parse `text` in `format` and merge the labels in. Returns the number added. + @discardableResult + mutating func merge(_ text: String, format: SymbolFormat) -> Int { + let before = labels.count + for rawLine in text.split(whereSeparator: \.isNewline) { + if labels.count >= Self.maxLabels { break } + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix(";") || line.hasPrefix("#") { continue } + switch format { + case .sym: parseSym(line) + case .mlb: parseMlb(line) + case .nl: parseNl(line) + } + } + return labels.count - before + } + + // MARK: - Per-format parsers (each total; a bad line is skipped) + + private mutating func parseSym(_ line: String) { + // "ADDR LABEL" — ADDR may be "$8000", "8000", or "00:8000". + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2 else { return } + guard let addr = Self.parseHexAddr(String(parts[0])) else { return } + let label = String(parts[1]) + if !label.isEmpty { labels[addr] = label } + } + + private mutating func parseMlb(_ line: String) { + // "MemoryType:Address:Label[:Comment]". We only map labelled entries. + let fields = line.split(separator: ":", omittingEmptySubsequences: false) + guard fields.count >= 3 else { return } + guard let addr = Self.parseHexAddr(String(fields[1])) else { return } + let label = fields[2].trimmingCharacters(in: .whitespaces) + if !label.isEmpty { labels[addr] = label } + } + + private mutating func parseNl(_ line: String) { + // "$ADDR#Name#Comment". + let fields = line.split(separator: "#", omittingEmptySubsequences: false) + guard fields.count >= 2 else { return } + guard let addr = Self.parseHexAddr(String(fields[0])) else { return } + let name = fields[1].trimmingCharacters(in: .whitespaces) + if !name.isEmpty { labels[addr] = name } + } + + /// Parse a hex address token (`$8000`, `8000`, or `bank:8000`) into a `UInt16`. + /// Returns nil for anything that does not fit (so a 24-bit ROM offset, etc., is + /// safely skipped rather than truncated into a wrong CPU address). + private static func parseHexAddr(_ token: String) -> UInt16? { + var t = token.trimmingCharacters(in: .whitespaces) + if let colon = t.lastIndex(of: ":") { t = String(t[t.index(after: colon)...]) } + if t.hasPrefix("$") { t.removeFirst() } + // Parse the value and range-check it against the 16-bit CPU address space, + // independent of the token's string length (so "$8000", "8000", and a + // zero-padded "008000" all work, while a 24-bit ROM offset is rejected + // rather than truncated into a wrong CPU address). + guard !t.isEmpty, let value = UInt(t, radix: 16), value <= 0xFFFF else { return nil } + return UInt16(value) + } +} diff --git a/ios/RustyNES/TAStudioView.swift b/ios/RustyNES/TAStudioView.swift new file mode 100644 index 000000000..ae052f0a1 --- /dev/null +++ b/ios/RustyNES/TAStudioView.swift @@ -0,0 +1,223 @@ +// +// TAStudioView.swift +// +// A pragmatic touch TAStudio piano-roll (v1.9.9 "Workshop"). The user authors a +// host-side table of per-frame P1 inputs (8 button columns); "Play" injects the +// table one mask per frame through the EXISTING bridge (deterministic input via +// setButtons + runFrame), and "Save as .rnm" arms the core's recorder and replays +// the table so the captured input is written to a real native `.rnm` movie. +// +// This stays within the additive mobile bridge surface: it adds no movie-editing +// API to the core. It does NOT pause emulation (playback needs the frame loop +// running), mirroring the Movies panel. +// + +import Combine +import SwiftUI + +struct TAStudioView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + + /// One NES button column: a display glyph + its mask bit (matching the bridge's + /// `Buttons` order: A, B, Select, Start, Up, Down, Left, Right). + private struct Btn: Identifiable { + let glyph: String + let mask: UInt8 + var id: UInt8 { mask } + } + private let buttons: [Btn] = [ + Btn(glyph: "A", mask: 0x01), Btn(glyph: "B", mask: 0x02), + Btn(glyph: "Se", mask: 0x04), Btn(glyph: "St", mask: 0x08), + Btn(glyph: "\u{2191}", mask: 0x10), Btn(glyph: "\u{2193}", mask: 0x20), + Btn(glyph: "\u{2190}", mask: 0x40), Btn(glyph: "\u{2192}", mask: 0x80), + ] + + /// The per-frame P1 masks being authored. + @State private var frames: [UInt8] = Array(repeating: 0, count: 30) + @State private var exporting = false + + /// Polls for scripted-export completion when exporting to `.rnm`. This is a + /// connectable (NOT autoconnected) publisher: it is connected ONLY while an export + /// is in flight — `startExport` connects it, `finishExport` (and a vanished core) + /// cancel it — so it consumes no CPU when the panel is idle. + private let pollTimer = Timer.publish(every: 0.2, on: .main, in: .common) + @State private var pollConnection: Cancellable? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + controlBar + Divider() + header + pianoRoll + } + .navigationTitle("TAStudio") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .onReceive(pollTimer) { _ in + // The timer only fires while connected (during an export); the guard + // is a belt-and-suspenders no-op otherwise. Save once the core hands + // back the finished movie. The core stops recording at the exact last + // authored frame, so the saved `.rnm` carries no trailing idle frames. + guard exporting else { return } + if let bytes = model.emulator?.tasTakeExportedMovie() { + finishExport(bytes) + } else if model.emulator == nil { + stopPolling() + exporting = false + } + } + .onDisappear { stopPolling() } + } + } + + // MARK: - Controls + + private var controlBar: some View { + VStack(spacing: 8) { + HStack { + Button { + frames.append(contentsOf: Array(repeating: 0, count: 30)) + } label: { + Label("Add 30", systemImage: "plus") + } + .buttonStyle(.bordered) + Button(role: .destructive) { + frames = Array(repeating: 0, count: 30) + } label: { + Label("Clear", systemImage: "trash") + } + .buttonStyle(.bordered) + Spacer() + Text(String(format: String(localized: "%lld frames"), frames.count)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + HStack { + Button { + model.emulator?.tasStartPlayback(p1Masks: frames) + } label: { + Label("Play", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(model.emulator == nil || frames.isEmpty) + + Button { + model.emulator?.tasStop() + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(.bordered) + + Spacer() + + Button { + startExport() + } label: { + Label("Save .rnm", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .disabled(model.emulator == nil || frames.isEmpty || exporting) + } + // Saving records from power-on, so it restarts the running game (Play + // does not). Surfaced so the restart is not a surprise. + Text("Saving restarts the game to record from power-on.") + .font(.caption2) + .foregroundStyle(.secondary) + if exporting { + Label("Recording the playback to a movie\u{2026}", systemImage: "record.circle") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding() + } + + private var header: some View { + HStack(spacing: 4) { + Text("Frame") + .font(.caption2.bold()) + .frame(width: 56, alignment: .leading) + ForEach(buttons) { b in + Text(b.glyph) + .font(.caption2.bold()) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal) + .padding(.vertical, 4) + .foregroundStyle(.secondary) + } + + private var pianoRoll: some View { + ScrollView { + LazyVStack(spacing: 2) { + ForEach(frames.indices, id: \.self) { i in + frameRow(i) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + private func frameRow(_ i: Int) -> some View { + HStack(spacing: 4) { + Text("\(i)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .leading) + ForEach(buttons) { b in + let on = (frames[i] & b.mask) != 0 + Button { + frames[i] ^= b.mask + } label: { + Text(on ? b.glyph : "") + .font(.caption2.bold()) + .frame(maxWidth: .infinity, minHeight: 28) + .background(on ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + .buttonStyle(.plain) + .accessibilityLabel( + Text(String(format: String(localized: "Frame %lld, %@"), i, b.glyph)) + ) + .accessibilityValue(on ? Text("On") : Text("Off")) + } + } + } + + // MARK: - Export to .rnm + + /// Arm the core's recorder from power-on and replay the authored table as one + /// export: the core stops recording at the exact last authored frame and hands + /// the finished bytes back via `tasTakeExportedMovie()`, which the poll timer + /// drains into `finishExport`. + private func startExport() { + guard let e = model.emulator, !frames.isEmpty else { return } + e.tasStartExport(p1Masks: frames) + exporting = true + // Connect the poll timer only now that an export is actually in flight. + pollConnection = pollTimer.connect() + } + + private func finishExport(_ bytes: Data) { + exporting = false + stopPolling() + guard !bytes.isEmpty else { return } + let name = (model.currentEntry?.name ?? "tas") + "-tas" + try? model.movies.save(bytes, gameName: name) + } + + /// Tear down the poll-timer connection so it stops firing when no export is + /// pending. Idempotent. + private func stopPolling() { + pollConnection?.cancel() + pollConnection = nil + } +}