Skip to content

Commit e02998b

Browse files
doublegateclaude
andauthored
v2.7.4 "Pocket": the mobile apps survive what a phone does to them (#552)
* fix(mobile): input never waits for a frame; cap ROM buffers at 16 MiB The first two Rust-bridge items of v2.7.4, from the frontend audit (docs/audits/frontend-audit-report.md §2.2, §2.4). `rustynes-mobile` is shared by the Android and iOS hosts, so both platforms get both fixes. MOB-01 -- input waited on the frame lock `NesController` kept the per-port masks inside `Mutex<Inner>`, the lock `run_frame` holds for a whole frame, a netplay rollback (up to 7 frames re-simulated), a Lua `on_frame` callback and a RetroAchievements evaluation. `set_buttons` / `set_button` / `buttons` took the same lock, so a touch or gamepad event on the UI thread waited out whatever the emulation thread was doing. The masks are now `[AtomicU8; 4]` on the controller, outside the lock: `set_buttons` is a store, `set_button` a single `fetch_or` / `fetch_and` (so a press and a release racing on two threads cannot lose a bit), `buttons` a load. `latch_input` copies them into the core under the lock immediately before each frame runs, and before `pre_tick_movie`, so movie playback still overrides live input and recording captures what the frame consumes. That latch point is where a lock-waiting call used to land, so emulated input timing is unchanged. The Android triage for this release found the same residual from the Kotlin side (AND-07): event handlers only call in on a mask change, but each change still blocked on this mutex. This closes it without touching Kotlin. Red first: `input_does_not_wait_for_the_frame_lock` holds the lock itself and calls `set_buttons` / `set_button` / `buttons` from another thread; on the old code the call timed out after 2 s. `held_input_reaches_the_core_at_the_next_frame` pins the latch. Mutations: no latch in `step_frame` (caught); `set_button` as a load-then-store instead of an atomic RMW (caught). One step was removed rather than kept: `load_state` used to re-apply the masks to the core after a restore. With every frame latching first, that re-apply was unobservable -- removing it left every test green, because nothing emulates in between -- so it is gone, with a comment at the site, and `load_state_preserves_held_input` now also checks the core has the held button after the next frame. MOB-02 -- ROM buffers were not size-checked `decompress_rom` bounded zip entries at 16 MiB but returned any non-zip buffer unchecked, and the core then copies the whole buffer (`rom_bytes`) and splits it into PRG and CHR. The cartridge loader ignores trailing bytes, so a VALID image padded to any size loaded. `check_rom_size` now refuses a buffer over `MAX_ROM_BYTES` (16 MiB, hoisted to module level and shared with the zip bound) in both `NesController::new` and `load_rom`, before `decompress_rom`. The caller's own buffer already exists at that point; this stops the copies after it. Red first: `a_rom_over_the_size_cap_is_refused_at_the_bridge` pads `tiny_nrom()` to 16 MiB + 1 and loaded on the old code. Mutations: no check in `new` (caught), `>` as `>=` (caught by the exactly-at-cap case). Verification: `cargo test -p rustynes-mobile --lib` 30 passed, 0 failed; clippy `-D warnings` and fmt clean. docs/android.md records both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * feat(mobile): export and import battery save RAM through the bridge MOB-05 / AND-09 of the frontend audit, and the mobile half of FE-01: the bridge exposed no battery RAM at all, so neither the Android nor the iOS app could keep an in-game save across a relaunch. The desktop gained `.sav` persistence in v2.7.3; this is the shared Rust half for mobile. The Kotlin and Swift halves that write the file follow in this release. API (`NesController`, UniFFI-exported, so both hosts get it): - `has_battery()` -- the header's battery bit. Same rule as the desktop: the save RAM slice is not the question, because NROM, MMC1 and MMC3 expose work RAM whatever the header says. - `battery_ram()` -- a copy of the save RAM, or empty without a battery. No dirty flag: the host compares with what it last wrote, as the desktop does. - `load_battery_ram(bytes)` -- load a `.sav` before the first frame. Refused, with nothing copied, when the cartridge has no battery or the length is not its save size; a partial copy would mix two saves. - `MobileError::Battery { reason }`, appended last. No Kotlin or Swift code matches `MobileError` exhaustively (checked), so the generated enums grow without breaking a host build. Keying: each host already stores save states per ROM, so the `.sav` goes beside them under the same key and the bridge needs no key API. That settles the v2.7.3 carry-over question for mobile without changing either host's layout. Tests (red as a compile error, the API not existing): `a_battery_save_round_trips_through_the_bridge` (a synthetic NROM with the battery bit writes $6000 every frame; a fresh controller takes the bytes back), `a_cart_without_a_battery_has_nothing_to_persist`, `a_battery_save_of_the_wrong_size_is_refused` (0, 8191 and 8193 bytes; RAM unchanged). Four mutations, each caught: the export ignoring the battery gate, the import ignoring it, the import copying nothing, and the size check removed. Verification: `cargo test -p rustynes-mobile --lib` 33 passed; clippy clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(netplay,cheevos): stop joining network workers on the dropping thread MOB-04 of the frontend audit (report §2.5): `SignalingClient::drop` (rustynes-netplay) and `HttpTransport::drop` (rustynes-cheevos) closed their channel and then joined the worker unconditionally. A worker blocked on the network held the dropping thread until the network gave up: the signaling worker's WebSocket handshake runs under the 10 s `CONNECT_TIMEOUT`, and the RA HTTP worker's requests under a 30 s global timeout. On mobile that thread is the UI thread leaving a netplay screen or logging out; on the desktop it is the winit thread. The audit named both sites correctly. Both now call `join_or_detach(worker, 100 ms)`: join if the worker finishes within the grace period, otherwise drop the handle, which detaches the thread. The grace covers the normal case -- an idle signaling worker notices the closed channel within one 20 ms read timeout, an idle HTTP worker is parked in `recv` and exits at once -- so only a worker stuck on the network is left to finish on its own. Why detaching is safe, per site, since that is what a reviewer should check: - signaling: the worker owns its socket and its halves of both channels, both already closed from our side; it exits when the handshake or read returns and touches nothing the caller frees. - RA HTTP: the worker owns its `ureq` agent and the request. It never calls the rcheevos callback; only `poll_completions` does, on the caller's thread, from a receiver dropped with `self`. So a late response is simply discarded. The audit suggested an explicit acknowledgement protocol with a deadline; the bounded `is_finished` poll is that, without a new channel. Red first: `dropping_the_client_does_not_wait_for_a_silent_relay` points a real `SignalingClient` at a local listener that accepts and never answers; `drop` took 10.06 s on the old code and 0.30 s of test time now (the 200 ms before it is the test letting the worker reach the handshake). For the RA transport, whose worker only blocks with a live rcheevos request in flight, the helper itself is pinned: `a_busy_worker_is_detached_not_joined` (a 5 s worker is detached within the grace; a finished one is joined). Mutation there -- always join -- is caught (the test waits the full 5 s and fails). Verification: `cargo test -p rustynes-netplay` 9 suites, 103 passed; `cargo test -p rustynes-cheevos` 9 passed; clippy -D warnings clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(core): a failed save-state load leaves the machine as it was MOB-08 of the frontend audit (report §8 row; `rustynes-core/src/nes.rs`). The finding is filed under mobile, but the defect is in the core, so every host had it: desktop, libretro, and both mobile apps. `Nes::restore_inner` applies a blob in stages that mutate as they go: the bus sections (BUS / PPU / APU / MAP) first, then the CPU section, then the cross-section clock check added in v2.7.0. A blob rejected at any stage after the first -- a CPU section version mismatch, a CPU body that fails to decode, a missing CPU section, a CPU/PPU clock skew -- returned `Err` with the bus already overwritten. The machine was then the blob's bus and the running game's CPU, neither state, and the next frame emulated it. The user guide's troubleshooting page already promised "the running emulator state is unchanged" on a failed load; that was false until now. Fix: the stages move into `apply_snapshot`, and a LOUD restore (`restore`, user-driven loads) snapshots the running machine first and re-applies that backup if any stage fails, then returns the original error. Our own fresh snapshot always restores -- the round-trip invariant every save-state test pins -- so the re-apply result is ignored rather than allowed to mask the real error. Deliberately NOT done for `restore_quiet`: it is run-ahead's and netplay's per-frame rollback, the hot path, and it only ever restores snapshots this core just wrote, which cannot fail these checks. A backup there would add a full snapshot per frame for no case that occurs. Rewind clearing and the debug-hooks attribution clears already ran only after success, so they need no change. The timeline-generation bump stays unconditional (its comment is updated: a failed loud restore no longer leaves a discontinuity, so the bump costs a consumer one telemetry reset). Red first: `a_failed_restore_leaves_the_machine_untouched` takes a snapshot at frame 1, runs to frame 4, then restores the frame-1 blob with its CPU section version decremented (the existing stale-slot fixture). On the old code the machine's snapshot afterwards differed from before the attempt. Mutations, all caught: no backup taken; backup taken but not re-applied; backup taken only on the quiet path. The success path is unchanged; AccuracyCoin, nestest and the full test-roms suite are re-run at the release gate. Verification: `cargo test -p rustynes-core --lib` 201 passed; clippy clean; `no_std` thumbv7em build clean. docs/frontend.md and the troubleshooting guide record it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(mobile): contain panics at the FFI boundary and say when it happened MOB-03, MOB-07 and MOB-09 of the frontend audit, plus the Rust half of FE-03 and AND-06. The headline is that MOB-03 was worse than the audit said. MOB-03 -- every panic killed the mobile apps The audit asked for `catch_unwind` on the hand-written JNI (`rustynes-android`) and C-ABI (`rustynes-ios`) entry points, assuming UniFFI's generated methods were already protected. Neither held in shipped builds: the Android `.so` and the iOS static library are built `--release`, and the workspace `release` profile sets `panic = "abort"`. Under abort nothing reaches a catch -- not the guard the audit proposed, and not the `catch_unwind` UniFFI wraps each exported method in -- so any panic anywhere in the bridge terminated the app with no auto-save and no error report. - New `[profile.release-mobile]`: `release` plus `panic = "unwind"`. Used by Gradle's `cargoNdkBuild`, `android.yml`, and `scripts/build-ios-xcframework.sh` (including every `target/<triple>/release/` path, now `release-mobile/`). Desktop, libretro and wasm keep `release` and its abort. - `rustynes_mobile::catch_ffi_panic` runs a body under `catch_unwind` and returns the panic message; each shim's `guarded(entry, fallback, body)` logs it and returns a harmless value (a null handle, a dropped frame, 0). All 6 JNI entries and all 14 C-ABI entries are wrapped (counted, not assumed). Cost, measured on arm64 (`cargo ndk`, both builds of this tree): librustynes_android.so 11.64 MB -> 13.74 MB (+18%) librustynes_mobile.so 6.21 MB -> 7.32 MB (+18%) Frame time, criterion `nes_run_frame_nestest_fast`, interleaved A/B on one host: abort 3.947 / 3.936 ms, unwind 3.949 / 3.940 ms -- +0.04% to +0.08%, noise. So the price is size, not speed. Reverting is one line in Cargo.toml. Verification: `catch_ffi_panic` has a host unit test. The Android shim compiled under `release-mobile` via Gradle's cargo-ndk step (both ABIs). The iOS shim cannot be compiled here (the vendored C needs an Apple SDK, and PR CI never builds iOS -- only the tag-triggered `ios.yml` does), so `ffi.rs` was type-checked on Linux in a scratch crate that includes the real file via `#[path]` against stubs of `gfx_metal` / `audio` (the real `audio_dsp`); 9 tests passed there, one calling a wrapped entry. The real Metal/CoreAudio modules remain uncompiled until the maintainer's iOS build. MOB-07 -- a recovered poison was silent Poisoning only became reachable with unwind: a panic in a method holding `inner` is now caught by UniFFI and leaves `Inner` possibly half-updated. `NesController::lock` used to take the state and carry on. It now ends any netplay session (the peers are simulating a game this one may have left mid-frame), queues `HostWarning::RecoveredFromInternalError`, and clears the poison so the warning fires once per panic. It does not power-cycle: that would throw away unsaved progress without asking. The new variant needed the host sides in the same change, because both match `HostWarning` exhaustively: Kotlin `hostWarningText` + `host_warning_recovered_from_internal_error` (EN, ES), Swift `warningText` + a `Localizable.xcstrings` entry (EN, ES). `recovering_from_a_panic_ends_netplay_and_warns_once` poisons the lock with a real panic; three mutations caught (silent recovery, poison not cleared, netplay surviving). MOB-09 -- TURN DNS under the frame lock `np_host_room` / `np_join_room` ran `to_nat_config`, which resolves the TURN host, while holding `inner`, stalling the emulation thread's next frame for as long as DNS took. It now runs before the lock. `NatConnect` construction stays under it, so a concurrent `load_rom` cannot swap the ROM under the session's hash. Not under test: a test cannot make DNS slow deterministically; the order is verified by reading. (The Android callers already use Dispatchers.IO; the iOS callers are on the main actor and are fixed in the Swift tier.) FE-03 and AND-06 (docs) FE-03 (report §2.1, raw handles dereferenced as `&mut` across threads) was REFUTED on both platforms by tracing every caller: all Android native calls on a handle come from its one `nes-gl` render thread; all iOS calls are on the main thread. Both shims now document that invariant and that it rests on the host convention, and the Android `nativeDestroy` SAFETY comment no longer claims the Kotlin side nulls a field (the handle is a local). AND-06: the manifest, module docs, `docs/android.md` diagram and (by dated amendment) ADR 0024 claimed an AAudio sink and focus listener that were never built; audio is Kotlin's `AudioTrack`. Corrected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(android): keep battery saves, write atomically, stop when stopped The Android tier of the v2.7.4 frontend audit line (AND-01..05, 08, 09), triaged against the code before any change; the per-item verdicts go in the frontend ledger. AND-07 needed no Kotlin change (its residual was the bridge mutex, fixed by MOB-01); AND-06 was documentation, fixed with the Rust tier; AND-10 is recorded as won't-fix (trim callbacks do not change the low-memory killer's score, which the audit claimed). AND-09 -- battery saves (the mobile half of FE-01) `Battery.kt`: `BatteryStore` puts the save at `filesDir/battery/<rom-sha256>.sav` (the save-state key), and `BatterySaver` applies the desktop's rules: only a battery cartridge, size checked from metadata BEFORE reading, a wrong-size or unreadable file never loaded and never overwritten that session, "clean" means equal to the last write. The `.sav` is loaded before the first frame and before the auto-resume state (which is newer and restores cartridge RAM too). It is compared once a second from the loop with the write on Dispatchers.IO (at most one in flight), and written synchronously in the background save, on close, and on dispose. On a ROM switch the outgoing save is written AFTER the new controller is swapped in: the loop no longer runs the old one, so its RAM is final, and the write never blocks the UI thread. AND-05 -- non-atomic writes `writeAtomic` over `androidx.core.util.AtomicFile` (side file, fsync, rename; `failWrite` on any error) now backs save-state slots, RA progress, the recents list and the battery save -- and `GameLibrary` / `GameConfig` too, which the audit did not name but hold user data (favourites, box art, per-game filters) behind the same truncating `writeText`. The androidx class is plain Java, so the tests run the real thing. AND-04 -- main-thread I/O `loadRom` split into `prepareRom` (the stream read, the hash, the `.sav` and auto-state loads, the persistable grant, the recents and library writes) on Dispatchers.IO, and `publishRom` (swap in, per-game filter) on Main, via one `openRom` helper for all four entry points; the SAF display-name query moved to the I/O side too. The save-slot sheet reads its timestamps and thumbnails in one `produceState` on IO, and its Save / Load / Delete run there. The background save in `onPause` stays synchronous on purpose: the process may be frozen or killed as soon as it returns. AND-03 -- running and playing in the background The Activity pauses emulation in `onStop` and resumes in `onStart` only what it paused, so a game the player paused stays paused. Keyed on STOP, not PAUSE: picture-in-picture calls `onPause` without `onStop` and must keep playing. `AudioPlayer` now requests AUDIOFOCUS_GAIN; on a loss (transient or not) it drops samples (the loop paces on its wall-clock floor), ducks to 0.2 on CAN_DUCK, restores on GAIN, pauses the track and abandons focus while emulation is paused, and re-requests focus on resume, since a permanent loss is not handed back. Emulation is not paused for focus: a notification sound should not stop a game. AND-01 / AND-08 -- the render thread Each render thread is a `Worker` with its own stop token. Before, one shared `running` flag served all of them and `surfaceCreated` refused to start a thread while a stalled one lived, leaving the view black until the next background/foreground. The audit's fix ("set running = true regardless") would have revived the old thread, bound to the dead surface. Now a new surface always gets a new worker, which joins its predecessor before any native call (keeping the one-native-user invariant FE-03 rests on), and only the CURRENT worker may consume pending surface state, checked under the same lock that retires the old one, so a retiring thread cannot steal the new surface's init. The 2 ms sleep-poll (up to 500 wakeups a second idle) is a park with a one-frame safety timeout; every producer unparks. AND-02 -- recomposition The per-frame `frame` read sat inside inline Row -> Column -> Box, so each new frame re-ran all of `EmulatorScreen`. The picture now reads it inside `FrameScope`, its own restart scope, and the body's idle-screen test uses a `derivedStateOf { frame != null }` that changes only when a game opens or closes. Deferred with the reason: skipping the per-frame bitmap pack on the GPU path (the triage's second suggestion) would break screenshots, clips and casting, which read that bitmap; it needs an on-demand capture path first. Tests: the app had no unit tests. `app/src/test` + JUnit 4 + `unitTests.isReturnDefaultValues` (so `android.util.Log` stubs return instead of throwing). `PersistenceTest`, 7 tests: an interrupted write keeps the previous contents and leaves no side file; the battery rules (first write, no rewrite when unchanged, wrong size untouched, disabled never writes, and a `.sav` symlinked to /dev/zero refused from metadata). Mutations caught: `writeAtomic` as a plain write; always rewriting; the disable ignored; the metadata size check removed. That last one first came back NOT CAUGHT -- `read` catches the OutOfMemoryError a read-first provokes, so both paths returned `Unusable` -- and the test now asserts which check refused the file. Not under automated test, and on the maintainer's device checklist: the render thread restart (AND-01), recomposition counts (AND-02), background pause and audio focus (AND-03), I/O off the main thread (AND-04; StrictMode logs it in debug builds), and the park/unpark wake (AND-08). Verification: `./gradlew :app:testFossDebugUnitTest :app:compilePlayDebugKotlin` BUILD SUCCESSFUL (both flavors compile; 7 tests, 0 failed). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(ios): rate-control the audio sink and report a dead stream The Rust half of the iOS tier of the v2.7.4 frontend audit line: IOS-02, IOS-03, IOS-09 and the sink side of IOS-08, plus a CI check that compiles the iOS-only Rust on Linux for the first time. Where the code went. The ring, the rate control and the channel fan-out moved into a new host-compiled module, `audio_ring.rs` (the way `audio_dsp` already is), so they are unit-tested on Linux; `audio.rs` (still `cfg(target_os = "ios")`) keeps only the cpal wiring. - IOS-02, no dynamic rate control -- CONFIRMED, though not as the audit described it. The triage found the audit's mechanism wrong (the pacing is wall-clock at 60.0988 Hz, so the ring does not "quickly fill"): what was real is that nothing steered the queue, so it sat wherever the host clock and the audio clock drifted, up to a 250 ms cap, and the first callbacks underran because playback started empty. `Producer` now runs a 4-tap Hermite resampler steered by the ring's fill (the desktop's ±1% law), holding the queue at 50 ms; `Ring` starts draining only once the target is queued and re-arms after an underrun. The resampler is the same algorithm as `rustynes-frontend/src/resampler.rs` -- this project's own code, no provenance header -- copied because the iOS crate cannot depend on the desktop frontend; the copy says so and names its twin. `rate_control_steers_the_ring_to_its_target` starts the ring empty and at 9000 samples, feeds it at exactly the nominal rate, and requires it to settle within 25% of the target; pinning the ratio at 1.0 fails it. - IOS-03, per-sample atomics in the real-time callback -- CONFIRMED, severity overstated (nanoseconds per sample). `Ring::pop_into` does one index load and one store per chunk, into a buffer the callback allocates once at stream build, never per call. A cost property with no observable output, so a mutation back to per-sample indexing is expected to pass the tests; the site says so. - IOS-09, surround channels fed the left image -- REFUTED as unreachable (cpal 0.18 always negotiates stereo on iOS, and only the DSP-enabled path was affected), fixed as hygiene: `fan_out` gives extra channels the centre. - IOS-08 (sink side): the error callback only logged. A fatal kind -- the same three the desktop reopens on, which include how cpal reports a media-services reset -- now sets a flag exposed as `rustynes_ios_audio_is_invalid` (declared in `rustynes_ios.h`, panic-guarded like every other entry); the Swift tier polls it and rebuilds the sink. Every stream build also passes a 2 s timeout instead of `None`. Mutations on `audio_ring`, all caught: rate control pinned at 1.0; the start threshold removed; no re-arm after an underrun; surround channels back to the left image. `scripts/ios-host-typecheck.sh`, run in the CI lint job. Nothing on a pull request compiled `audio.rs` or `ffi.rs` -- only the tag-triggered `ios.yml` did, and that needs an Apple SDK -- so this release's FFI guards and these sink changes would first have compiled at release time. The script generates a crate under `target/` that includes the REAL `audio_dsp.rs`, `audio_ring.rs`, `audio.rs` and `ffi.rs` via `#[path]`, compiles `audio.rs` against the real cpal (its API is the same on every backend), stubs only `gfx_metal` (wgpu/Metal, which genuinely needs iOS), and runs 16 tests including one through a wrapped FFI entry. Shown to fail: a planted `u8`/`bool` mismatch in `ffi.rs` exits 101. It cannot check `gfx_metal.rs`, or runtime behaviour on the iOS backend. Verification: `cargo test -p rustynes-ios --lib` 15 passed (8 DSP + 7 ring); `scripts/ios-host-typecheck.sh` 16 passed; clippy -D warnings clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(ios): battery saves, 60 Hz display sync, background-safe cloud sync The Swift tier of the v2.7.4 frontend audit line, and the Swift halves of MOB-05, MOB-09 and IOS-08. Every item was triaged against the code first (verdicts go in the frontend ledger); IOS-06, IOS-07 and IOS-09 were refuted. NONE OF THIS IS COMPILED HERE. There is no Swift toolchain on this Linux machine, and PR CI never builds iOS (only the tag-triggered ios.yml does). The changes are kept small and conventional, every UniFFI call follows the label spelling of existing calls (`loadBatteryRam(bytes:)` like `loadPalette(bytes:)`), and the project is Swift 5 mode with an iOS 17 floor (checked in project.yml). The maintainer's device checklist has to build and exercise each item before the PR merges. - MOB-05, battery saves (`BatterySave.swift`): the desktop's and Android's rules -- battery bit only, size checked from attributes before reading, a wrong-size file never loaded and never overwritten that session, atomic writes on a private serial queue. Loaded before the first frame; compared once a second (write off the main thread); written synchronously on close and on a game switch, and inside a background task when the app backgrounds. - IOS-04 (`BackgroundTask.swift`): CloudKit uploads now run in a UIKit background task, which the helper always ends -- including from the expiration handler, because a task left running past its time gets the app terminated. The triage also found the reconcile marking a slot `.synced` when the LOCAL copy was newer and had never uploaded (a suspended or failed upload); it now re-uploads it. - IOS-10: uploads used `.allKeys` (client wins), so a device holding an older save that uploaded later replaced a newer one. `performUpload` fetches the server record, skips if it is newer (reconcile pulls it), otherwise updates that record and saves with `.ifServerRecordUnchanged`, so a concurrent write from another device fails the save instead of being lost. The per-record save result is now checked; it was discarded, so a per-record failure read as success. Wall-clock timestamps still order saves, but the overwrite window is closed by the change tag. - IOS-01: the display link asked for 60-120 Hz (preferring 120) and paced the core with a 60.0988 Hz wall-clock accumulator, which hitches about every 5 s at 120 Hz and every 10 s at 60 Hz (the triage's arithmetic). It now asks for exactly 60 Hz and, when the link really runs at ~60 Hz, runs exactly one console frame per vsync (display sync). The core then runs 0.16% slow, inside the +/-1% the new sink rate control absorbs. Any other refresh keeps the accumulator. - IOS-08: a media-services reset now re-configures the session and rebuilds the sink; the core also rebuilds any sink reporting its stream dead (`rustynes_ios_audio_is_invalid`), throttled to one attempt per 2 s, re-applying the audio-depth settings. A new hardware rate cannot be matched without rebuilding the core, and is logged. The adjacent defect the triage found -- an unplugged headset paused the game until a background/foreground cycle, with no in-app way out -- is now its own pause reason, cleared by the next menu close. - IOS-05: `.defersSystemGestures(on: .bottom)` and `.persistentSystemOverlays(.hidden)` on the game view (the pad already sat above the safe area; the risk was a thumb sliding off the bottom). - IOS-11: at `.serious` / `.critical` thermal state the video filter is suspended until the device cools; the chosen filter is untouched. - MOB-09 (Swift): `NetplayModel` is @mainactor and called `npJoin`, `npHostRoom` and `npJoinRoom` synchronously, running their DNS lookups on the main thread; they now run in detached tasks and publish back on the main actor. - Found by the triage, not in the audit: opening a ROM from another app while a game ran swapped the core under a `MetalGameView` whose coordinator kept the old one (renderer already destroyed) and never started the new one -- a frozen screen. The view is now keyed to its core with `.id`. - IOS-06 (refuted; GCController's handler queue defaults to main): the queue is now set to main explicitly, since the file's own comment assumed otherwise. - The `EmulatorCore` header claimed a separate display-link thread; the handles are touched only on the main thread, which is what FE-03's safety rests on. Corrected, with that warning. docs/ios.md records all of it, including the unverified status. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * docs(audits): record a verdict for all 30 mobile rows, and FE-03 The frontend ledger's AND-01..10, IOS-01..11 and MOB-01..09 rows were still "untriaged" from the v2.7.0 seeding. Each now carries a verdict, the evidence behind it, and the commit that acted on it: - Fixed and pinned by a red-first test where a test can observe it (MOB-01, 02, 04, 05, 07, 08 and the Android persistence path). - Refuted: IOS-06 (GCController.handlerQueue defaults to main and was never changed, so there was no race; set to main explicitly anyway, since the file's own comment assumed off-main delivery), IOS-07 (the display link runs on the main run loop, which drains its autorelease pool every iteration; the per-frame buffers are ARC Data and Rust Vecs), IOS-09 (unreachable: cpal 0.18 always negotiates stereo on iOS; the fan-out was fixed as hygiene with a caught mutation), and FE-03 (every caller traced: each shim's handles are driven from one thread only; the invariant is now documented in both shims). - MOB-03 recorded as worse than reported: the mobile libraries were built panic = "abort", so neither the proposed guards nor UniFFI's own could contain anything. - AND-10 won't-fix: the callbacks are indeed absent, but adding them does not change the low-memory killer's score, which depends on process state. - MOB-06 (the per-frame framebuffer copy through UniFFI) deferred to v2.7.5 with the other performance work, where it has to clear the >3% A/B bar. FE-03 is new: it is in the report's section 2.1 prose but not in its section 8 table, so the seeding missed it. A stray escaped quote in the MOB-03 row is also fixed. Also fixes a rustdoc failure in crates/rustynes-ios/src/audio_ring.rs, whose module preamble's intra-doc links to Ring, Ring::pop_into, Producer and fan_out did not resolve under `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`; they are now crate-qualified paths. Doc text only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * chore(release): cut v2.7.4 "Pocket" The fifth release of the ADR 0041 audit line: the mobile apps survive what a phone does to them. The fixes are the six commits before this one; this commit is the version bump and the release documents. - Workspace and rustynes-cosim to 2.7.4 via scripts/release-automation/bump_release.py (every current-release anchor, the libretro .info display version, the cosim lockfile). The nested "Built on" chains are trimmed back to one level in AGENTS, README, SUPPORT, VERSION-PLAN and to-dos/ROADMAP, as at every cut; OVERVIEW, SECURITY and STATUS keep the full chain by convention. - CHANGELOG [2.7.4], a VERSION-PLAN row (v2.7.3 loses "(current)"), the to-dos/ROADMAP release-line tail, the ROADMAP status line, and the maintainer-authored release-notes override that release-auto.yml publishes. - to-dos/plans/v2.7.4-pocket-plan.md with an As-built table, and the v2.7.4 row of the line plan marked as built. - docs/mobile-v2.7.4-device-checklist.md: 2 build steps, 12 Android and 13 iOS rows, each naming the finding it checks. It exists because of the 2026-09-22 decision that mobile work is done in all tiers and a device run gates the merge. What this release does NOT establish, stated here so the tag does not imply it: no Swift in v2.7.4 has been compiled -- this machine has no Xcode and no pull-request job builds iOS; the Android render-thread restart, audio focus, background pause, recomposition scoping and park/unpark have no automated test; and the iOS audio-sink rebuild and 60 Hz display sync have not run on a device. The Rust halves are host-tested (scripts/ios-host-typecheck.sh, now a CI step). The checklist covers each; the release notes say the same. Verification on this tree: cargo test --workspace --features test-roms 154 suites, 2,716 passed, 0 failed AccuracyCoin (RAM decoder): 144 total, 0 fail nestest: pass release_anchor_audit 15/15, release_state_prose_audit 2/2, release_notes_render_audit 8/8 fmt, clippy (every feature combo AGENTS.md lists, both wasm32), cargo doc -D warnings, thumbv7em no_std build: clean Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(mobile): contain a mid-frame panic in the bridge and freeze the game Correction to MOB-03 as shipped earlier on this branch: unwinding did not keep the app open. v2.7.4 switched the mobile libraries to panic = "unwind" so panics could be caught, and the CHANGELOG, release notes and warning text all said that after one "the game keeps running". For the calls that matter most, that was false. Mechanism. UniFFI does catch a panic in every exported method, but what it does next depends on whether the method is declared fallible. run_frame, step_frame and np_advance_frame are not, so: - Swift: the generated wrapper is `try! rustCall { ... }` (verified by generating the bindings from the host library with the project's own uniffi-bindgen 0.32.1: rustynes_mobile.swift, `open func runFrame()`). The caught panic becomes UniffiInternalError.rustPanic, and `try!` turns that into a crash. - Kotlin: the wrapper throws InternalException("Rust panic"). The frame loop in MainActivity runs in a Compose LaunchedEffect with a try/finally and no catch, and an exception escaping a LaunchedEffect is rethrown by the recomposer, which crashes the app (read from the code, not reproduced). So a core panic at 60 Hz still closed both apps; the new JNI / C-ABI guards only covered the renderer and audio shims. Fix. NesController::contained_frame runs a frame body under catch_ffi_panic inside the bridge. The panic unwinds out of the body, which drops the guard and poisons the mutex; taking the lock again runs the MOB-07 recovery (ends netplay, queues RecoveredFromInternalError once), and that recovery now also sets a new `frozen` flag. A frozen machine runs no further emulated cycle: run_frame returns the current framebuffer, step_frame does nothing, np_advance_frame reports a stall. A fresh start thaws it: load_rom, power_cycle, or a successful load_state (a whole snapshot replaces whatever the panic left behind). Reset deliberately does not, since it keeps most of the machine's state. Why freeze rather than carry on: this answers the one part of agy's round-1 "silent failure" finding that held. After a panic the core may be half-way through an update; running on from there could, for instance, let a confused game overwrite its own battery save, which the phones now persist periodically. Freezing keeps the RAM as the game last wrote it. The audit's own MOB-07 remedy was a power-cycle; freezing keeps the picture on screen and leaves the choice to the player. The warning text says so in all five places (Rust default, Android EN/ES, Swift, xcstrings EN/ES): "RustyNES stopped the game after an internal error. Any netplay session was ended. Reopen the game or load a save state to continue." Scope, deliberately: only the three frame paths are wrapped. They are the calls that execute emulated cycles and the ones made sixty times a second, so where a core defect surfaces. Every other exported method keeps UniFFI's guard, so a panic there still reaches the host as an exception; the ledger row says so. Test: a_panic_mid_frame_freezes_the_game_instead_of_reaching_the_host, via a cfg(test) thread-local fault injector compiled out of real builds. It failed first (the panic escaped run_frame). Six mutations, each reverting one piece: freeze not set, no catch, frozen check removed, and each of the three thaws removed -- all CAUGHT. The load_rom thaw was first NOT CAUGHT (stimulus blind): a fresh Nes reports frame 1 and its first frame does not advance the counter, so `frame() == 1` held frozen or not. The test now runs two frames and asserts the counter moved, as for the power cycle, which has the same first-frame property. Clippy: an allow for struct_excessive_bools on Inner (four independent flags; an enum would encode combinations that are all legal). Verified: cargo test -p rustynes-mobile 36 passed; clippy -D warnings on rustynes-mobile/-android/-ios all targets; rustdoc -D warnings; scripts/ios-host-typecheck.sh; fmt. The string resources parse. NOT verified: the Swift and Kotlin sides of the new text on a device -- the checklist's A12 row is updated to the new expectation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(android,ios): close three races and two stale docs from review Copilot round 1 on #552 made five findings; all five held when checked against the code. Three are races this branch introduced or left behind, two are documentation that now contradicts the code. 1. Android battery: a late periodic flush could overwrite the final save. The periodic job ran `saver.flushIfChanged(ctrl.batteryRam())` on Dispatchers.IO, so the RAM was read BEFORE the saver's lock. A job that read, then waited on the lock while a final flush (close, ROM switch, onStop) wrote newer RAM, then wrote its older copy over it -- losing up to a second of in-game progress in the case the final flush exists for. There is no cross-ROM mix-up (each ROM gets its own NesController and BatterySaver, and the job holds both), which I checked because it would have been the worse failure. Fix: flushIfChanged takes a reader, `() -> ByteArray`, and calls it under the lock; all three call sites pass `{ ctrl.batteryRam() }`. A late flush then reads RAM at least as new as the final one and finds it unchanged. The Rust mutex the reader takes is never held while calling back into Kotlin, so there is no lock-order cycle. New JVM test a_late_periodic_flush_does_not_overwrite_the_final_one pins the saver's contract; that each call site passes a reader and not bytes read earlier is a property of MainActivity, checked by reading it and not by the test, which says so. 2. Android render worker: `worker = next` was published before `next.thread` was assigned, and `wake()` -- called from the emulation thread by submitFrame / setFilter / surfaceChanged -- reads `worker?.thread`. Reading an unassigned `lateinit` throws UninitializedPropertyAccessException, so a frame submitted in that window crashed the loop. Fix: the Thread is constructed inside the same synchronized block, before the worker is published (the @volatile write of `worker` then publishes the assignment); it is started after the lock as before. Not under automated test (a timing window on a device thread); device checklist A7 exercises surface churn. 3. iOS netplay: the join / host-room / join-room calls moved to Task.detached in this release (MOB-09), and their completions were not tied to the game. Closing or swapping the game while a DNS lookup or relay handshake was in flight let the completion publish a session on the OLD core and start polling it, leaving an orphaned session nothing ever left. Fix: an attach/detach generation counter; each operation captures it, and a completion from a stale generation calls npLeave on the core it used (if a session came up) and publishes nothing, errors included. Swift, uncompiled here, like the rest of the iOS changes. 4. README: the paragraph after the current-release anchor still described v2.7.3 (2,698 tests; "the mobile apps still keep [battery saves] only inside a save state"). Rewritten for v2.7.4: 2,716 tests, all three hosts keep battery saves, and the device checklist named. 5. CloudSaveStateSync.swift: the file header still said uploads were force-overwrite / client-wins and that local-newer slots were already uploaded -- the exact behaviour IOS-10 and IOS-04 removed. A later edit trusting that summary could reintroduce the data loss. Rewritten to the conditional policy, with the pre-v2.7.4 behaviour named as history. Also corrected while searching for more of (4): docs/mappers.md still said "the desktop and mobile frontends do not persist cartridge battery RAM at all", which has been false for the desktop since v2.7.3; it now lists each host's `.sav` and the battery-bit gate they share. Verified: Android `:app:testFossDebugUnitTest` 8 tests, 0 failures (7 before + the new one) and `:app:compilePlayDebugKotlin`, both with the Rust build tasks excluded (no bridge API changed). Swift not compiled. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(mobile): act on CodeRabbit round 1, and make CI run the iOS check CodeRabbit's round 1 on #552 made ten findings. Nine held against the code and are fixed here; one was already fixed in the previous commit (the render-worker publication order, also found by Copilot). Plus a CI failure of my own. CI: the new "iOS shim + audio sink host type-check" step failed with exit 126. scripts/ios-host-typecheck.sh was committed 100644: this repository runs core.fileMode=false (the NAS convention), so the executable bit set locally was never recorded. `git update-index --chmod=+x` fixes the index. Because that step failed, the three lint-job steps after it -- clippy with `full`, clippy on the mappers with mapper-audio compiled out, and the commercial-roms harness compile -- did not run in CI on this PR (rustdoc and the other clippy combos run before it and passed). This push runs them. The warning nobody saw (MOB-07). Both apps drained host warnings only after a movie load, so RecoveredFromInternalError -- queued when the bridge contains a panic -- never reached the screen, and "the app says so" in the notes was not true. Android now drains every 15 frames in the loop (the RA / netplay cadence); iOS polls once a second while a game is open. The Android frame loop now catches InternalException around each iteration. The previous commit contains panics inside the bridge for the three frame paths; any other bridge call the loop makes (npIsActive, drainAudioBytes, indexFramebufferBytes, ...) still reaches Kotlin as InternalException, which would end the LaunchedEffect and crash the app. Caught, it is logged, the loop waits one frame, and the next bridge call recovers the lock, freezes the game and queues the warning. (The diff for MainActivity is large because the loop body is re-indented into the try; the logic change is the catch and the poll.) Android battery, same file (AND-09). Reopening the running game gives the outgoing and incoming sessions separate BatterySavers for one `.sav`; their instance locks did not exclude each other, so their AtomicFile writes could meet in the one staging file per path. Savers now share a per-path lock. I tried to pin this first: a JVM stress test of two savers writing 8 KiB images concurrently passed 3 of 3 runs on the UNFIXED code (each image is a single write call on the JVM), so it could not fail and was not kept; the lock is structural and says so at the site. Separately, openRom now writes the running game's save before preparing the new ROM, so a reopen reads it back missing at most the frames run during preparation rather than up to a second. iOS audio ring soundness (IOS-02/03). Moving Ring out of audio.rs made it public, with `&self` push / pop_into on a `Sync` type: safe code could call push from two threads, both load the same tail and write one UnsafeCell -- undefined behaviour reachable without `unsafe`. Ring is private again; `ring(cap, threshold)` returns one RingTx and one RingRx, neither Clone, both `&mut self`, so the single-producer / single-consumer rule the unsafe relies on is enforced by the types. Producer owns the RingTx; the cpal callback owns the RingRx. The tests are rewritten against the handles; behaviour is unchanged (15 rustynes-ios tests, 16 in the host type-check). iOS audio retry (IOS-08). A rebuild that destroyed the sink and failed to open a new one left `audio` nil, and recoverAudioIfNeeded returned early on nil -- so the log said "retrying" and nothing did. An audioLost flag keeps the retry going on the existing 2 s throttle; a sink that never opened at init stays silent as before. iOS thermal (IOS-11). A thermal transition called applyDisplaySettings, which also re-reads the palette and the HD pack from disk on the main actor. It now applies the filter only. iOS netplay (MOB-09). CodeRabbit refined Copilot's finding fixed in the previous commit: the stale-completion npLeave has no session identity, so it is safe only if nothing newer could have started on the same core. Now only one join / host runs at a time (the others report "Still connecting"), and Leave also bumps the generation, so a completion that lands after Leave ends its own session instead of reviving one the player left. A native per-operation generation, as suggested, would also work but changes the bridge API for a case the one-at-a-time rule already closes. Docs: the uniffi-bindgen example path is release-mobile/ (it still said release/); docs/android.md now carries the size and frame-time figures its "measured below" pointed at nothing for; to-dos/ROADMAP.md no longer says AccuracyCoin "reads 143/144 from v2.6.17" -- it has read 144/144 since v2.6.18. The release notes, checklist (I14, I15) and ledger (seven rows annotated "Review (#552)") follow. Also from agy round 1: the battery background task in AppModel now ends in a `defer`. agy named BatterySave.swift, which begins no task, and flushNow cannot throw, so there was no leak; the defer is for the next early return. Verified: cargo test -p rustynes-mobile 36 and -p rustynes-ios 15; clippy -D warnings on the three mobile crates; rustdoc; ios-host-typecheck (16); Android :app:testFossDebugUnitTest 8/8 and :app:compilePlayDebugKotlin; release audits 15/2/8; markdownlint. NOT verified: every Swift change (uncompiled here) and the Android loop's catch, which needs a device panic. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj * fix(core,ios): assert the restore rollback, log iCloud upload failures From agy round 2 on #552. Two changes; the rest of the round is answered in the PR. nes.rs: the MOB-08 rollback ignored the result of re-applying the backup (`let _ = self.apply_snapshot(&backup)`). That was deliberate and commented: the backup is this core's own fresh snapshot, the round-trip every save-state test pins, and the ORIGINAL error is the one worth returning. agy's point that a broken invariant would pass silently is still fair, and the core is no_std with no logger, so the result is now debug_assert!-ed. Debug and test builds fail loudly if the invariant ever breaks; the release build is unchanged (the assert compiles out), so no golden or byte-identity is affected. CloudSaveStateSync.upload: both failure branches returned nil with nothing logged -- the per-record failure (typically serverRecordChanged, another device wrote first) and a thrown modifyRecords error. Each is now logged with the slot and the error, which is what a device-checklist run of I7/I8 needs to tell a conflict from an outage. Behaviour is unchanged. Swift, uncompiled here. Verified: cargo test -p rustynes-core a_failed_restore (1 passed), clippy -D warnings on rustynes-core all targets, thumbv7em no_std build. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 79a5b41 commit e02998b

65 files changed

Lines changed: 4000 additions & 924 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/release-notes/v2.7.4.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# RustyNES v2.7.4 — "Pocket"
2+
3+
The fifth release of the audit line that [ADR 0041](https://github.com/doublegate/RustyNES/blob/main/docs/adr/0041-hardware-release-is-v3.0.0.md) put between v2.6.23 and the SuperStation One core (**v3.0.0**). It works from the frontend audit in [`docs/audits/`](https://github.com/doublegate/RustyNES/blob/main/docs/audits/README.md), and its subject is the two mobile apps: what happens to them when a phone does what phones do — backgrounds them, takes a call, loses a headset, runs out of memory mid-write, or hits a bug.
4+
5+
All 30 mobile findings were checked against the code before anything changed. Four were refuted, one was deferred, and one turned out much worse than reported; every verdict is in the [frontend ledger](https://github.com/doublegate/RustyNES/blob/main/docs/audits/frontend-disposition.md). One fix is in the emulation core and affects every platform, so **AccuracyCoin 144/144** and **nestest** were re-measured on the release tree, and the full `--features test-roms` workspace suite passes **2,716 tests, 0 failed**.
6+
7+
**Most of this release needs a real phone to confirm.** Every iOS change is written but not compiled on this project's Linux machines, and the Android lifecycle and audio changes need a device; both are on the maintainer's per-platform device checklist, which gates this release's merge. The Android battery-save path was run on the emulator. **The MiSTer sibling does not change, and no hardware has run any bitstream**; the board session is v2.9.2.
8+
9+
## A bug no longer closes the app
10+
11+
The Android and iOS libraries were built to stop the process on any internal error. The audit asked for error containment at the native entry points, assuming the rest of the bridge was already protected; neither held, because in that build mode nothing can contain an error — not the new guards, and not the ones the binding generator already puts around every call. So one bug anywhere ended the app with no save and no report.
12+
13+
The mobile libraries now build with a profile that recovers (`release-mobile`), and every native entry point contains a failure. That was not enough on its own, as review found: the binding generator's own catch hands a failure back to the app as an error the per-frame calls never expect — on iOS that ends the app anyway. So a failure while a frame runs is now caught inside the library. The app stays open, the game freezes instead of running on from a half-updated machine (where it could, for instance, overwrite its own save), any netplay session ends, and the app says so; reopening the game or loading a save state continues. Measured cost: about 18% more native code on arm64, and no measurable difference in frame time.
14+
15+
## Battery saves on the phones
16+
17+
v2.7.3 gave the desktop in-game saves. Android and iOS now keep them too, with the same rules: only cartridges whose header says they have a battery, a save loaded before the game's first frame, and a save file that cannot be used left untouched rather than overwritten. On the Android emulator a test cartridge that counts its own power-ons read 1, 2 and 3 across three force-closed launches.
18+
19+
## Android
20+
21+
- **The game pauses when the app leaves the screen**, and the app now takes part in audio focus: it goes quiet for a call, ducks under a navigation prompt, and hands the audio back while paused. Picture-in-picture keeps playing.
22+
- **Saves survive a dying phone.** Save states, achievement progress, the recent-games list, the library and per-game settings are written so that an interrupted write leaves the previous copy, not a truncated one.
23+
- Opening a game and the save-state screen no longer do their file work on the interface thread, where a slow cloud-backed file could freeze the app.
24+
- The GPU renderer recovers after a slow screen teardown instead of staying black, and no longer wakes hundreds of times a second while idle. The game screen no longer rebuilds all of its interface on every frame.
25+
26+
## iOS
27+
28+
- **Audio stays in step.** The iOS sound output had no rate control; it now holds a steady 50 ms buffer, starts from a full one, and comes back after the system's media services reset. Unplugging headphones no longer leaves the game frozen until the app is sent to the background.
29+
- **A smoother picture.** The display runs at 60 Hz with exactly one emulated frame per refresh; the previous 120 Hz link showed a frame twice or skipped one every few seconds.
30+
- **iCloud save states are not lost.** Uploads finish when the app is backgrounded, a save whose upload failed is retried instead of shown as synced, and an older save from one device can no longer overwrite a newer one from another.
31+
- A swipe off the bottom of the controls no longer sends the app home; video filters pause while the device is hot; opening a ROM from another app while a game is running no longer freezes the screen; joining an online game no longer freezes the interface during the address lookup.
32+
33+
## Every platform
34+
35+
- **Touch and gamepad input never wait for the emulator.** A button press waited for the current frame, a netplay rollback or a script to finish.
36+
- **A failed save-state load leaves the game as it was.** A state rejected partway through left a half-restored machine that then kept running — on the desktop and in RetroArch too.
37+
- Leaving netplay or logging out of RetroAchievements no longer freezes the app for up to 10-30 seconds when the server is unreachable.
38+
- ROM files over 16 MiB are refused before they are copied.
39+
40+
## Verification
41+
42+
| Check | Result |
43+
| --- | --- |
44+
| AccuracyCoin (RAM decoder) | 144 / 144, 0 fail, 0 not run |
45+
| nestest | pass |
46+
| `--features test-roms` workspace suite | 154 suites, 2,716 passed, 0 failed |
47+
| fmt, clippy (default, `scripting,hd-pack`, `retroachievements`, `av-record`, `script-ipc`, `full`, both wasm32), `cargo doc -D warnings`, `no_std` build | clean |
48+
| Android: Gradle, both flavours, and the app's first JVM unit tests | compile; 8 tests pass |
49+
| Android emulator | battery-save boot counter 1, 2, 3 across force-closed launches |
50+
| iOS-only Rust (`scripts/ios-host-typecheck.sh`, now in CI) | compiles on Linux against the real audio library; 16 tests pass |
51+
| Red first | every fix a test can observe was pinned by a test that fails on v2.7.3 (the netplay teardown test waited 10.06 s) |
52+
| Mutations | each such fix reverted in turn and caught; the few that are expected not to be caught (a performance-only change, a redundant second safety check) say so at the site |
53+
54+
**Not under automated test:** every Swift change; on Android the render-thread restart, the frame-rebuild scoping, the background pause, audio focus, the idle wake-up, and the frame loop surviving a bridge error. The maintainer's device checklist covers each; anything it could not confirm is listed in the release's known issues.
55+
56+
**Deferred:** removing the per-frame copy of the picture through the mobile bridge (about 15 MB/s) is a performance change and moves to v2.7.5 with the others.
57+
58+
**Next:** v2.7.5 is performance and API hygiene, each change kept only if it measures faster and changes nothing.

‎.github/workflows/android.yml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,13 @@ jobs:
129129
- name: Cross-compile mobile + android (arm64 + x86_64)
130130
run: |
131131
cargo ndk -t arm64-v8a -t x86_64 --platform 26 \
132-
build --release -p rustynes-mobile -p rustynes-android
132+
build --profile release-mobile -p rustynes-mobile -p rustynes-android
133133
134134
- name: Generate Kotlin bindings (UniFFI smoke)
135135
run: |
136136
cargo run -q -p rustynes-mobile --bin uniffi-bindgen -- \
137137
generate \
138-
--library target/aarch64-linux-android/release/librustynes_mobile.so \
138+
--library target/aarch64-linux-android/release-mobile/librustynes_mobile.so \
139139
--language kotlin --out-dir target/uniffi-kotlin --no-format
140140
test -f target/uniffi-kotlin/uniffi/rustynes_mobile/rustynes_mobile.kt
141141
@@ -146,7 +146,7 @@ jobs:
146146
- name: Verify 16 KB ELF alignment (arm64)
147147
run: |
148148
set -euo pipefail
149-
so=target/aarch64-linux-android/release/librustynes_mobile.so
149+
so=target/aarch64-linux-android/release-mobile/librustynes_mobile.so
150150
bad=$("${ANDROID_NDK_HOME}"/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf -l "$so" \
151151
| awk '/LOAD/ { if (strtonum($NF) < 0x4000) print }')
152152
if [ -n "$bad" ]; then
@@ -159,8 +159,8 @@ jobs:
159159
with:
160160
name: rustynes-android-so
161161
path: |
162-
target/aarch64-linux-android/release/librustynes_*.so
163-
target/x86_64-linux-android/release/librustynes_*.so
162+
target/aarch64-linux-android/release-mobile/librustynes_*.so
163+
target/x86_64-linux-android/release-mobile/librustynes_*.so
164164
165165
gradle-bundle:
166166
name: Gradle bundle foss+play release (best-effort packaging)

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,13 @@ jobs:
446446
run: cargo clippy -p rustynes-frontend --all-targets --features scripting,hd-pack -- -D warnings
447447
- name: clippy (retroachievements — rcheevos FFI)
448448
run: cargo clippy -p rustynes-frontend --all-targets --features retroachievements -- -D warnings
449+
# v2.7.4: rustynes-ios's iOS-only modules (the C-ABI shim and the CoreAudio
450+
# sink) are `cfg(target_os = "ios")`, so no PR job compiled them -- only the
451+
# tag-triggered ios.yml did. This type-checks the real files on Linux (cpal
452+
# has the same API on every backend) with only the Metal renderer stubbed,
453+
# and runs their tests. See the script's header for what it cannot check.
454+
- name: iOS shim + audio sink host type-check
455+
run: scripts/ios-host-typecheck.sh
449456
# The `full` umbrella (retroachievements + scripting + script-ipc + hd-pack +
450457
# debug-hooks + av-record) is the catch-all that lints the native features the
451458
# three subset combos above DON'T reach on their own — notably `script-ipc` and

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Enforcement lives alongside the prose: `/ref-proj/` is gitignored/`.dockerignore
4343

4444
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`).
4545

46-
**Current release: v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** (2026-09-23) — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. v2.7.3 changes the frontends and adds one core accessor (`Nes::has_battery`); **AccuracyCoin 144/144 and nestest 0-diff** were re-measured on the v2.7.3 tree anyway, and the full `--features test-roms` suite passes 2,698 tests. The co-simulation ladder is **155 gates green, 0 failed, 1 expected failure**, and the sibling's bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold). **No hardware has run any bitstream**. The board session is v2.9.2 and the hardware-verified core is **v3.0.0** ([ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)); v2.7.x and v2.8.x act on the four audits in `docs/audits/` first. Per-release detail lives in `CHANGELOG.md` and the GitHub releases; it is deliberately not duplicated here.
46+
**Current release: v2.7.4 "Pocket"** (2026-09-24) — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. v2.7.4 changes the mobile apps and one core path (a rejected save-state load now rolls back), so **AccuracyCoin 144/144 and nestest 0-diff** were re-measured on the v2.7.4 tree, and the full `--features test-roms` suite passes 2,716 tests. Every Swift change and the Android lifecycle changes wait on the maintainer's device checklist (`docs/mobile-v2.7.4-device-checklist.md`). The co-simulation ladder is **155 gates green, 0 failed, 1 expected failure**, and the sibling's bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold). **No hardware has run any bitstream**. The board session is v2.9.2 and the hardware-verified core is **v3.0.0** ([ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)); v2.7.x and v2.8.x act on the four audits in `docs/audits/` first. Per-release detail lives in `CHANGELOG.md` and the GitHub releases; it is deliberately not duplicated here.
4747

4848
- **Timebase (v2.0.0)** — the scheduler substrate is rewritten from a five-counter dot-lockstep model to a single canonical cycle counter, every CPU cycle a real bus access, and a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up (ADR 0002 / ADR 0029), now the *only* scheduler path. This is a MAJOR-boundary breaking change (ADR 0003): `.rns` save-state and `.rnm` movie format epochs bump (ADR 0028) — a pre-v2.0.0 `.rns` slot now fails to load with a clear error instead of silently misinterpreting stale bytes. Landed across five betas + rc.1 (PRs #217–223). Also new: core-level **Vs. `DualSystem`** dual-console support (`Emu::Dual`, `crates/rustynes-core`) for the four Vs. arcade cabinet boards — core-and-test-harness-only, frontend wiring deferred. The R1/R2 MMC3 IRQ-timing residual is by-design-deferred beyond this release with a mechanism-level finding recorded in ADR 0002 (not closed, not silently dropped). **AccuracyCoin now measures 141/141 (100.00%)**: the v2.0.1 upstream AccuracyCoin re-sync grew the catalog to 146 rows / 141 assigned tests and briefly opened two new PPU gaps ("ALE + Read" $0491, "Hybrid Addresses" $0492), which **v2.0.3** closed by promoting the 2-cycle-ALE PPU fetch model to the unconditional default (both experimental flags retired; additive `PPU_SNAPSHOT_VERSION` v5 tail). AccuracyCoin held 100% (139/139) throughout the v2.0.0 betas and final cut, dipped to 139/141 under the v2.0.1 re-sync, and is back to a full 141/141 from v2.0.3 onward.
4949

@@ -225,7 +225,7 @@ that is a reason to add a tenth — not a reason to grow this section back.
225225
- `ref-docs/` is immutable. Research updates go in dated supplemental files.
226226
- ADRs go in `docs/adr/` (Michael Nygard format).
227227
- `rustynes-core` re-exports the public types from the chip crates; downstream consumers (`rustynes-frontend`, `rustynes-test-harness`) should depend on `rustynes-core` rather than the chip crates directly.
228-
- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.7.3 "Hearth"** (2026-09-23). **Never claim any version *later* than v2.7.3 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and is the one release that broke byte-identity and save-state compatibility, by design. The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not.
228+
- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.7.4 "Pocket"** (2026-09-24). **Never claim any version *later* than v2.7.4 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and is the one release that broke byte-identity and save-state compatibility, by design. The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not.
229229
- **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive.
230230
- The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`.
231231
- **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.49.1`). The pin was v0.39.0 until the v2.6.3 dependency refresh, held because the newer local binary reported rules the pin lacked — chiefly **MD060** (`table-column-style`), which was therefore NOT gated. That is now measured and resolved: MD060's inferred default reads this corpus as style `compact` and reports **1,936 findings across 122 files** and nothing else, so `.markdownlint.json` pins `MD060` to the style actually in use (`leading_and_trailing`), which measures **zero** and rewrites no document. It IS a gate now. Still verify with `pre-commit run markdownlint --all-files` rather than the bare binary — the pin and the local build can drift apart again. `.markdownlint.json` also keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/`<img>`, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/` (the reference-emulator clone, now removed from disk but kept in the ignore lists as a firewall guard so it can never re-enter the tree — see the MOST IMPORTANT RULE section above), the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those.

‎ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Document Version:** 2.1.0
44
**Last Updated:** 2026-08-30
5-
**Applies to:** RustyNES v2.7.3 (the scheduling model is v2.0.0 "Timebase" onward)
5+
**Applies to:** RustyNES v2.7.4 (the scheduling model is v2.0.0 "Timebase" onward)
66

77
This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion.
88

0 commit comments

Comments
 (0)