Skip to content

Commit 70a971b

Browse files
doublegateclaude
andauthored
feat(v1.9.9): "Workshop" — iOS creator/power tools + final readiness gate (#204)
* feat(v1.9.9): "Workshop" — iOS creator/power tools + final readiness gate The last iOS TestFlight release before v2.0.0 "Timebase". Brings the desktop creator/power-tools to touch and runs a full pre-freeze readiness pass (host-gate verification, security audit, gap analysis, completeness-critic). Additive / off-by-default: the bridge gains only forwarding fns over existing rustynes-core APIs, the deterministic core / chip crates are untouched, the shipped / native / no_std / wasm core stays byte-identical, and AccuracyCoin holds 100% (139/139). Creator tools: - Cheats: Game Genie editor (persistent) + raw-RAM peek/poke ($0000-$1FFF). - Read-only debugger inspector (FOSS/TestFlight build only, gated off the App-Store channel per ADR 0027): CPU regs, disassembly window, hex view, single-frame step, over new observational bridge fns. - TAStudio piano-roll: touch frame-input table + scripted playback + record-to-.rnm from power-on. - Foreign movie import .fm2/.bk2/.fcm/.fmv/.vmv -> native .rnm (bounds-checked, 16 MiB per-ZIP-member cap, graceful errors). - Audio depth (rustynes-ios host DSP): 5-band EQ, panning, Schroeder reverb, crossfeed; bypassable to a bit-identical passthrough. - Host-side symbol maps (.sym/.mlb/.nl). Readiness pass / fixes: - File imports + foreign-movie transcode moved off the main thread. - poke_ram clamps the address to the work-RAM mirror bridge-side. - Missed EN/ES catalog strings + VoiceOver labels added across the new views. - TAStudio .rnm export no longer captures trailing idle frames. - Mobile ROM loading is iNES/NES 2.0 only: the picker + Info.plist no longer advertise .fds/.nsf (the shared bridge has no FDS/NSF load path) -- FDS/NSF on mobile are a post-v2.0.0 carryover. - docs/ios-v1.9.9-readiness.md records the full gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(v1.9.9): address PR #204 review — security scope, RAM bounds, TAStudio Resolve the gemini + copilot threads on v1.9.9 "Workshop" (all in ios/RustyNES/): - Security-scoped resource (gemini HIGH x4): hoist startAccessingSecurityScoped- Resource() + its defer out of Task.detached onto the main-actor caller so the scope brackets the await (acquiring it inside the detached task can fail). Fixed in ROMLibrary, PaletteManager, AppModel.importForeignMovie, DebuggerView; also fixed the identical pre-existing pattern in HDPackStore.importPack. - importForeignMovie no longer captures the non-Sendable EmulatorCore in a detached closure: the task returns only Data; the transcode runs back on main. - CheatsView raw-RAM editor validates the address to $0000-$1FFF (parsedAddr is nil out-of-range), so Peek/Poke disable and can't alias into the mirror. - tasStartExport guards against an empty frame table so the recorder is never left armed. - SymbolMap parses hex with UInt(_,radix:16) + <= 0xFFFF range check instead of a fragile length test. - TAStudio frame-count label uses the localized "%lld frames" key; the poll timer only runs during an active export (connectable publisher), idle-CPU-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fa00dd6 commit 70a971b

31 files changed

Lines changed: 3352 additions & 147 deletions

‎CHANGELOG.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
1616
## [Unreleased]
1717

18+
## [1.9.9] - 2026-06-26 - "Workshop" (iOS creator / power tools + readiness gate)
19+
20+
The final iOS TestFlight release before the v2.0.0 "Timebase" core rewrite — the
21+
analogue of Android v1.8.9. It brings the desktop creator/power-tools to touch and
22+
runs a full pre-freeze readiness pass (verification, security audit, gap analysis, a
23+
completeness-critic sweep). Everything is **additive / off-by-default**: the bridge
24+
gains only new forwarding fns over **existing** `rustynes-core` APIs (no signature or
25+
behaviour change), the deterministic core / chip crates are untouched, and the
26+
shipped / native / `no_std` / wasm core stays **byte-identical** — **AccuracyCoin
27+
holds 100% (139/139)**. Interim TestFlight.
28+
29+
### Added
30+
31+
- **Cheats** — a Game Genie editor (persistent live codes via the core engine,
32+
applied on every PRG read) plus one-shot raw-RAM peek / poke of the `$0000–$1FFF`
33+
work-RAM region, behind a new Cheats sheet.
34+
- **Read-only debugger inspector** (FOSS / TestFlight build only, gated off the
35+
App-Store channel per ADR 0027) — live CPU registers, a disassembly window around
36+
the PC, a hex memory view, and single-frame step, over new observational bridge
37+
fns (`debug_cpu_state` / `debug_read_memory` / `debug_disassemble`).
38+
- **TAStudio piano-roll** — a touch frame-input table with deterministic scripted
39+
playback and record-to-`.rnm` from power-on.
40+
- **Foreign movie import** — `.fm2` / `.bk2` / `.fcm` / `.fmv` / `.vmv` transcoded to
41+
the native `.rnm` format via new bridge importers (bounds-checked: 16 MiB
42+
per-ZIP-member cap, graceful errors on malformed input — never panics).
43+
- **Audio depth** — a host-side DSP stage in `rustynes-ios` (5-band EQ, per-channel
44+
panning, Schroeder reverb, crossfeed), fully bypassable (disabled / flat / centered
45+
is bit-for-bit the v1.9.8 passthrough; the cpal callback drains one sample per
46+
output frame regardless of state).
47+
- **Symbol maps** — host-side `.sym` / `.mlb` / `.nl` label loading feeding the
48+
debugger view.
49+
50+
### Changed
51+
52+
- **`rustynes-mobile` bridge** gains additive forwarding fns (cheats, foreign-movie
53+
importers, observational debugger reads) — the first iOS release to extend the
54+
shared bridge surface; all additive, so the Android build is unaffected and the
55+
core stays byte-identical.
56+
57+
### Fixed
58+
59+
- File imports (ROM, palette, symbol map) and foreign-movie transcoding now run off
60+
the main thread (`Task.detached`), so a large user-picked file no longer hangs the
61+
UI; `poke_ram` clamps the address to the work-RAM mirror bridge-side; missed
62+
EN/ES catalog strings and VoiceOver labels across the new creator-tool views added.
63+
64+
### Notes
65+
66+
- **Mobile ROM loading is iNES / NES 2.0 only.** The import picker no longer
67+
advertises `.fds` / `.nsf` (the shared bridge has no FDS / NSF load path) — FDS
68+
disk + NSF playback on mobile are a post-v2.0.0 carryover, alongside the 20-band EQ,
69+
`.dbg` ca65/cc65 source maps, and a cheats database.
70+
1871
## [1.9.8] - 2026-06-26 - "Horizon" (iOS store-readiness)
1972

2073
The iOS store-readiness release — the analogue of Android v1.8.8 "Atlas". A broad

‎Cargo.lock‎

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ members = [
2121
]
2222

2323
[workspace.package]
24-
version = "1.9.8"
24+
version = "1.9.9"
2525
edition = "2024"
2626
rust-version = "1.96"
2727
license = "MIT OR Apache-2.0"

‎android/app/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ android {
3939
applicationId = "com.doublegate.rustynes"
4040
minSdk = 26 // AAudio floor.
4141
targetSdk = 36 // Play mandate (Android 16) from 2026-08-31.
42-
versionCode = 10908 // 1.9.8
43-
versionName = "1.9.8"
42+
versionCode = 10909 // 1.9.9
43+
versionName = "1.9.9"
4444
// No abiFilters here — set per buildType so release ships arm64 only
4545
// while debug keeps x86_64 for the emulator.
4646
// PLAY_BUILD gates the freemium (demo timer + persistence locks + Billing).

‎crates/rustynes-ios/include/rustynes_ios.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ void rustynes_ios_audio_push(RustyNesAudioSink *handle, const float *samples, si
7272
/* The negotiated device sample rate (request it from NesController::new). 0 if NULL. */
7373
uint32_t rustynes_ios_audio_sample_rate(RustyNesAudioSink *handle);
7474

75+
/* Publish the live audio-depth DSP config (v1.9.9): EQ / pan / reverb / crossfeed,
76+
* applied host-side after drain_audio. `eq` is up to 5 band gains (dB), `pan` up to
77+
* 6 per-channel positions (-1..=1); extra entries ignored, missing ones neutral.
78+
* `enabled == 0` (or a flat / centered config) is a bit-exact passthrough. The
79+
* `eq` / `pan` pointers may be NULL (treated as neutral). No-op on a NULL handle. */
80+
void rustynes_ios_audio_set_depth(RustyNesAudioSink *handle, uint8_t enabled,
81+
const float *eq, size_t eq_len,
82+
const float *pan, size_t pan_len,
83+
float reverb_mix, float reverb_room, float crossfeed);
84+
7585
/* Pause / resume the output stream (scene background / audio interruption). */
7686
void rustynes_ios_audio_pause(RustyNesAudioSink *handle);
7787
void rustynes_ios_audio_resume(RustyNesAudioSink *handle);

‎crates/rustynes-ios/src/audio.rs‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use std::sync::Arc;
2424

2525
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
2626

27+
use crate::audio_dsp::{AudioDepth, DepthConfig, DepthParams};
28+
2729
/// A single-producer / single-consumer lock-free ring of mono `f32` samples.
2830
///
2931
/// The producer ([`AudioSink::push`], called on the emu thread) only advances
@@ -122,6 +124,12 @@ pub struct AudioSink {
122124
ring: Arc<Ring>,
123125
stream: cpal::Stream,
124126
sample_rate: u32,
127+
/// The live audio-depth (EQ / pan / reverb / crossfeed) configuration mailbox
128+
/// (v1.9.9). The Swift Settings UI publishes a [`DepthConfig`] via
129+
/// [`AudioSink::set_depth`]; the real-time callback snapshots it once per
130+
/// buffer. Default / disabled = bit-exact passthrough, so the determinism
131+
/// contract is untouched.
132+
depth_params: Arc<DepthParams>,
125133
}
126134

127135
impl AudioSink {
@@ -148,16 +156,46 @@ impl AudioSink {
148156
let ring = Arc::new(Ring::new((sample_rate as usize) / 4));
149157
let ring_cb = Arc::clone(&ring);
150158

159+
// The audio-depth DSP (v1.9.9): the callback owns a stateful processor
160+
// (EQ biquad history + reverb delay lines, allocated once) and reads the
161+
// live config from the shared `DepthParams` mailbox once per buffer.
162+
let depth_params = Arc::new(DepthParams::new());
163+
let depth_params_cb = Arc::clone(&depth_params);
164+
let mut depth = AudioDepth::new(sample_rate);
165+
151166
let stream = device
152167
.build_output_stream(
153168
config,
154169
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
155-
// Expand the mono ring to the device's interleaved channels:
156-
// one queued sample fans out to every channel of a frame.
170+
// Mirror the live config in once per buffer (cheap; re-voices
171+
// only on an actual change), then check bypass once.
172+
depth.apply(&depth_params_cb.snapshot());
173+
let bypass = depth.is_bypass();
157174
for frame in data.chunks_mut(channels) {
158-
let s = ring_cb.pop();
159-
for ch in frame.iter_mut() {
160-
*ch = s;
175+
// Exactly one queued mono sample is consumed per output
176+
// frame regardless of bypass, so toggling the DSP never
177+
// changes the drain rate.
178+
let mono = ring_cb.pop();
179+
if bypass {
180+
// Fan the mono value out to every channel (the
181+
// byte-identical pre-v1.9.9 behaviour).
182+
for ch in frame.iter_mut() {
183+
*ch = mono;
184+
}
185+
} else {
186+
let (l, r) = depth.process(mono);
187+
match frame {
188+
[] => {}
189+
[c0] => *c0 = 0.5 * (l + r),
190+
[c0, c1, rest @ ..] => {
191+
*c0 = l;
192+
*c1 = r;
193+
// Any surround channels get the left image.
194+
for ch in rest {
195+
*ch = l;
196+
}
197+
}
198+
}
161199
}
162200
}
163201
},
@@ -171,6 +209,7 @@ impl AudioSink {
171209
ring,
172210
stream,
173211
sample_rate,
212+
depth_params,
174213
})
175214
}
176215

@@ -195,4 +234,11 @@ impl AudioSink {
195234
pub fn sample_rate(&self) -> u32 {
196235
self.sample_rate
197236
}
237+
238+
/// Publish a new audio-depth (EQ / pan / reverb / crossfeed) configuration
239+
/// (v1.9.9). Lock-free: the real-time callback picks it up on its next buffer.
240+
/// A default / disabled config is a bit-exact passthrough.
241+
pub fn set_depth(&self, cfg: &DepthConfig) {
242+
self.depth_params.store(cfg);
243+
}
198244
}

0 commit comments

Comments
 (0)