Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 17 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions crates/rustynes-ios/include/rustynes_ios.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 51 additions & 5 deletions crates/rustynes-ios/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,6 +124,12 @@ pub struct AudioSink {
ring: Arc<Ring>,
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<DepthParams>,
}

impl AudioSink {
Expand All @@ -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;
}
}
}
}
}
},
Expand All @@ -171,6 +209,7 @@ impl AudioSink {
ring,
stream,
sample_rate,
depth_params,
})
}

Expand All @@ -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);
}
}
Loading