diff --git a/.github/release-notes/v2.0.6.md b/.github/release-notes/v2.0.6.md new file mode 100644 index 000000000..043248f1f --- /dev/null +++ b/.github/release-notes/v2.0.6.md @@ -0,0 +1,46 @@ +# RustyNES v2.0.6 — "Harbor" ("Parity") + +**iOS feature-parity finalization.** The sixth release of the v2.0.x "Harbor" mobile-finalization train, and the second of the four-release **iOS finalization window (v2.0.5 → v2.0.8)**. It runs on the byte-identical **v2.0.0 "Timebase"** core — **AccuracyCoin 141/141 (100.00%)**, nestest 0-diff, the `#![no_std]` chip stack untouched — so **no accuracy, save-state, or determinism number moves**. This is a host / iOS-only cut. + +No store submission happens here (that is the future v2.1.0 joint launch); v2.0.6 closes a host-authorable v1.9.9 parity gap and re-verifies the v1.9.x host features against the new core. + +--- + +## The core is unchanged (141/141) + +Nothing in the emulation core changed since v2.0.3. AccuracyCoin holds **141/141 (100.00%, RAM-authoritative)**, nestest is 0-diff, the 60-ROM commercial byte-identity oracle is unmoved. + +## New: opt-in crash reporting (privacy-first) + +The iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 readiness gap that listed an "opt-in crash-reporting surface" as an iOS-applicable deferral. + +- **Off by default.** Enabled from **Settings → Diagnostics**. With it off, the app collects and writes nothing — the **"Data Not Collected"** privacy nutrition label is unchanged, and `PrivacyInfo.xcprivacy` needs no edit. +- **Local-only.** When enabled, an uncaught-`NSException` handler writes a timestamped crash report (app version, device, OS, exception name / reason, call stack) to `Application Support/RustyNES/crash-logs/`, keeping the 10 most recent. **Nothing is uploaded** — the reports are for the user to read, **copy** to the clipboard, and share manually if they choose. A **Crash logs** viewer (list → per-log detail → Copy / Clear) lives in the same section. EN + ES. +- **Honest about scope.** `NSSetUncaughtExceptionHandler` catches the Objective-C / bridged `NSException` class (UIKit, KVO, bridged Foundation). Pure-Swift runtime traps (`fatalError`, force-unwrap of nil, out-of-bounds) abort via a POSIX signal an exception handler cannot see; writing files from a signal handler is async-signal-unsafe, so — exactly as the Android side documents Firebase Crashlytics as the maintainer option rather than pulling it in — Swift-trap capture is left to a real third-party reporter a maintainer can add later, gated on this same opt-in flag. The handler also re-checks the live opt-in at crash time, so opting back out stops new logs immediately. + +## Feature-parity re-verification + +The v1.9.x host features were re-checked against the **v2.0.0 bridge surface** (unchanged by the timebase rewrite, which touched only the scheduler internals): Game Center cloud saves / achievements, iCloud KV-store config + CloudKit `.rns` save-state sync, MFi / hardware controllers (hot-plug, P1–P4), ReplayKit capture / share + PiP, and the accessibility layer (VoiceOver, Dynamic Type, colorblind palettes). All continue to route through the same typed bridge and the re-ported save-state epoch. The audit is recorded in `docs/ios-v2.0.6-readiness.md`. + +## Verification (host) + +- **Core unchanged** — AccuracyCoin **141/141**, nestest 0-diff (not re-run; the chip stack is untouched). +- `cargo check --workspace` green after the version bump + `Cargo.lock` regen; `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -D warnings` (no issues), `RUSTDOCFLAGS=-D warnings cargo doc`, the `no_std` cross-compile, and both wasm32 clippy gates all green. +- `Localizable.xcstrings` remains valid JSON (EN source + the added ES entries). +- markdownlint (CI-pinned) clean on the new / edited docs. +- New Swift matches the app's existing SwiftUI baseline (the crash-log views follow the same `NavigationStack` / closure-`NavigationLink` / `.topBarTrailing` patterns already in Settings, so they add no availability regression) and is auto-included by xcodegen (`sources: path: RustyNES`); the Swift compile + on-device behaviour remain the documented macOS / TestFlight carryover (no Xcode on the Linux host). + +## Maintainer / on-device closeout (deferred to v2.0.9 / the TestFlight upload) + +- Build the app with **Xcode 26 / the iOS 26 SDK** (the App Store submission floor — a hard gate at v2.0.7) and confirm the new Diagnostics UI + crash-log viewer render on device. +- Force a test `NSException` on device and confirm a local log is written, viewable, copyable, and pruned to 10; confirm the toggle-off path stops new logs. +- The full v1.9.9 on-device TestFlight checklist (ROM import, save / rewind, MFi controller, audio interruptions, ProMotion pacing, an accurate privacy label). + +## What's next — the rest of "Harbor" + +- **v2.0.7** — iOS polish + performance + the **iOS 26 SDK / Xcode 26** App Store submission-floor readiness + the privacy-manifest / required-reason-API audit. +- **v2.0.8** — the iOS release candidate (App Store Connect listing staged, TestFlight external beta, App-Review self-audit). +- **v2.0.9** — joint on-device readiness verification for both apps. +- **v2.1.0** — the **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch**. + +Full detail: `CHANGELOG.md` `[2.0.6]` · `docs/STATUS.md` (single source of truth) · `docs/ios-v2.0.6-readiness.md` · `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e6e3ac4..9745c1c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ cycle-accurate core later replaced. ## [Unreleased] +## [2.0.6] - 2026-07-09 - "Harbor" (iOS feature parity — "Parity") + +- The second iOS finalization release (the v2.0.5–v2.0.8 window), on the + byte-identical v2.0.0 "Timebase" core: **AccuracyCoin 141/141**, nestest 0-diff, + the `#![no_std]` chip stack untouched. Host / iOS-only — no accuracy / save-state / + determinism number moves. +- **New opt-in crash-reporting surface** (privacy-first, **off by default**) — the + iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 readiness + gap. Enabled from **Settings → Diagnostics**, an uncaught-`NSException` handler + writes **local** crash logs (viewable + copyable in-app; **nothing is uploaded**, + so the "Data Not Collected" privacy label is unchanged). The handler re-checks the + live opt-in at crash time, so opting out stops new logs immediately. EN + ES. +- **Feature-parity re-verification** of the v1.9.x host features against the v2.0.0 + bridge (Game Center, CloudKit save sync, MFi controllers, capture / PiP, + accessibility) — all route through the unchanged bridge surface; recorded in + `docs/ios-v2.0.6-readiness.md`. +- Version bump: workspace `2.0.5 → 2.0.6`; iOS `MARKETING_VERSION → 2.0.6`. +- TestFlight-only; the App Store + AltStore PAL launch stays deferred to v2.1.0. + On-device crash-capture verification is flagged for the v2.0.9 readiness pass. + ## [2.0.5] - 2026-07-09 - "Harbor" (iOS re-port onto Timebase — "Landfall") - Opens the iOS finalization window (v2.0.5–v2.0.8) of the v2.0.x "Harbor" train: diff --git a/Cargo.lock b/Cargo.lock index fdef5a209..46f33619d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "2.0.5" +version = "2.0.6" dependencies = [ "android-activity", "android_logger", @@ -4266,7 +4266,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4279,7 +4279,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "2.0.5" +version = "2.0.6" dependencies = [ "cc", "ureq", @@ -4287,7 +4287,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4304,7 +4304,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4315,7 +4315,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "2.0.5" +version = "2.0.6" dependencies = [ "anstyle", "arboard", @@ -4367,11 +4367,11 @@ dependencies = [ [[package]] name = "rustynes-gfx-shaders" -version = "2.0.5" +version = "2.0.6" [[package]] name = "rustynes-hdpack" -version = "2.0.5" +version = "2.0.6" dependencies = [ "lewton", "png", @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bytemuck", "cpal", @@ -4396,7 +4396,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "2.0.5" +version = "2.0.6" dependencies = [ "libc", "rust-libretro", @@ -4405,7 +4405,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "2.0.5" +version = "2.0.6" dependencies = [ "rustynes-core", "rustynes-hdpack", @@ -4432,14 +4432,14 @@ dependencies = [ [[package]] name = "rustynes-monetization" -version = "2.0.5" +version = "2.0.6" dependencies = [ "uniffi", ] [[package]] name = "rustynes-netplay" -version = "2.0.5" +version = "2.0.6" dependencies = [ "futures-util", "js-sys", @@ -4455,7 +4455,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "2.0.5" +version = "2.0.6" dependencies = [ "bitflags 2.13.0", "criterion", @@ -4466,14 +4466,14 @@ dependencies = [ [[package]] name = "rustynes-ra" -version = "2.0.5" +version = "2.0.6" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "2.0.5" +version = "2.0.6" dependencies = [ "mlua", "piccolo", @@ -4484,7 +4484,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "2.0.5" +version = "2.0.6" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index a50478a6f..2bc37b80b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "2.0.5" +version = "2.0.6" edition = "2024" rust-version = "1.96" license = "MIT OR Apache-2.0" diff --git a/docs/STATUS.md b/docs/STATUS.md index 4c668f104..d811ca5b0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,27 @@ # RustyNES — Project Status Matrix -> **Current release: v2.0.5 "Harbor"** (2026-07-09) — the fifth release of the v2.0.x +> **Current release: v2.0.6 "Harbor"** (2026-07-09) — the sixth release of the v2.0.x +> mobile-finalization train, and the **second iOS finalization release** ("Parity"), +> continuing the iOS window (v2.0.5–v2.0.8). A **host / iOS-only** cut: the +> cycle-accurate emulation core is **unchanged and byte-identical to v2.0.5** — +> AccuracyCoin still **141/141 (100.00%, RAM-authoritative)**, nestest 0-diff, the +> `#![no_std]` chip stack untouched — so no accuracy / save-state / determinism number +> moves. It adds a **new opt-in, privacy-first crash-reporting surface** (off by +> default, the iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 +> iOS-applicable deferral): **Settings → Diagnostics** installs an +> uncaught-`NSException` handler that writes **local** crash logs the user can view + +> copy in-app — **nothing is uploaded**, so the "Data Not Collected" privacy label is +> unchanged (EN + ES); the handler re-checks the live opt-in at crash time so opting +> out stops new logs immediately. It also records the **feature-parity +> re-verification** of the v1.9.x host features (Game Center, CloudKit save sync, MFi +> controllers, capture / PiP, accessibility) against the unchanged v2.0.0 bridge +> surface. Version bump (workspace `2.0.5 -> 2.0.6`; iOS `MARKETING_VERSION -> 2.0.6`). +> **TestFlight-only**; App Store + AltStore PAL deferred to v2.1.0; on-device +> crash-capture verification is a **maintainer / v2.0.9** step. See `CHANGELOG.md` +> `[2.0.6]` + `docs/ios-v2.0.6-readiness.md` + +> `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. +> +> **The preceding release: v2.0.5 "Harbor"** (2026-07-09) — the fifth release of the v2.0.x > mobile-finalization train, and the **first iOS finalization release** ("Landfall"), > opening the iOS window (v2.0.5–v2.0.8) that mirrors the Android v2.0.1–v2.0.4 window. > This is a **host / iOS-only** cut: the cycle-accurate emulation core is **unchanged and @@ -24,7 +45,7 @@ > determinism smoke on Apple silicon. See `CHANGELOG.md` `[2.0.5]` + > `docs/ios-v2.0.5-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. > -> **The preceding release: v2.0.4 "Harbor"** (2026-07-08) — the fourth release of the v2.0.x +> **The release before that: v2.0.4 "Harbor"** (2026-07-08) — the fourth release of the v2.0.x > mobile-finalization train, and the **Android release-candidate** milestone. This is a > **host / Android-only** cut: the cycle-accurate emulation core is **unchanged and > byte-identical to v2.0.3** — AccuracyCoin still **141/141 (100.00%, RAM-authoritative)**, @@ -52,7 +73,7 @@ > them to the monetization `feature_enabled` façade without on-device validation). See > `CHANGELOG.md` `[2.0.4]` + `to-dos/plans/v2.0.4-android-rc-plan.md`. > -> **The release before that: v2.0.3 "Harbor"** (2026-07-08) — the third release of the v2.0.x +> **Earlier in the train: v2.0.3 "Harbor"** (2026-07-08) — the third release of the v2.0.x > mobile-finalization train, and the one that makes the octal-latch accuracy work > real at the shipped default. The **2-cycle-ALE PPU fetch model is promoted from the > experimental `mc-ppu-2cycle-ale` flag to the unconditional, only PPU fetch path** diff --git a/docs/ios-v2.0.6-readiness.md b/docs/ios-v2.0.6-readiness.md new file mode 100644 index 000000000..f672a7c86 --- /dev/null +++ b/docs/ios-v2.0.6-readiness.md @@ -0,0 +1,126 @@ +# RustyNES v2.0.6 "Harbor" ("Parity") — iOS feature-parity readiness + +Readiness record for **v2.0.6 "Parity"**, the second iOS/iPadOS release of the v2.0.x +"Harbor" mobile-finalization train and the second of the iOS finalization window +(**v2.0.5 → v2.0.8**). `docs/STATUS.md` remains the per-suite source of truth; this file +is the authoritative v2.0.6 summary. + +v2.0.6 is a **host / iOS-only** cut on the byte-identical v2.0.0 "Timebase" core: +**AccuracyCoin 141/141 (100.00%)**, nestest 0-diff, the `#![no_std]` chip stack +untouched. Every change is presentation / host-side. + +## 1. What landed + +### Opt-in crash reporting (`CrashReporter.swift`) + +The iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 readiness gap. + +- **Privacy-first, off by default.** `AppModel.crashReportingEnabled` (a `@Published` + UserDefaults-backed flag, **not** cloud-synced) gates everything; with it off the app + installs no handler and writes nothing, so the **"Data Not Collected"** label and + `PrivacyInfo.xcprivacy` are unchanged. Exposed as a **Settings → Diagnostics** toggle. +- **Local-only.** On opt-in, `CrashReporter.install(enabled:)` chains an + `NSSetUncaughtExceptionHandler` that writes a timestamped report (app version, device, + OS, exception name / reason, `callStackSymbols`) to + `Application Support/RustyNES/crash-logs/`, kept to the newest 10. **Nothing is + uploaded**; a `CrashLogsView` (list → detail → **Copy** to clipboard / **Clear**) lets + the user read and share manually (the detail view reads the log off-main via `.task` + + `Task.detached`). The crash-log directory is marked **excluded from iCloud / device + backups** (`isExcludedFromBackup`), so the logs never leave the device even through a + backup / restore — reinforcing "nothing is uploaded". New UI strings (including the + `(unreadable)` fallback) are localized **EN + ES**. +- **Crash-handler discipline.** The handler runs on an arbitrary thread in an unstable + post-crash process, so it does the bare minimum: all metadata (device / OS / app + version) and the log-directory URL are **pre-resolved and cached at `install()` on the + main thread** (`install` is `@MainActor`, since `UIDevice.current` is main-actor + isolated — reading it from the C callback would be an isolation violation), and log + **pruning** (directory enumeration + deletion) is done at launch, not in the handler. + The handler only formats one string and writes one file — no `UIDevice.current`, no + `Bundle.main.infoDictionary`, no directory creation, no enumeration. +- **Idempotent + runtime-honouring.** The handler installs once and re-checks the live + `crashReportingEnabled` flag at crash time, so opting back out stops new logs + immediately without needing to uninstall. It is installed at launch from + `AppModel.init()` (a `didSet` does not run for in-init assignment, so the install is + explicit) and lazily on the Settings toggle. +- **Honest scope.** `NSSetUncaughtExceptionHandler` catches the Objective-C / bridged + `NSException` class only; pure-Swift traps abort via a POSIX signal it cannot see, and + async-signal-unsafe file writes from a signal handler are unsound — so Swift-trap + capture is left to a maintainer's third-party reporter (gated on the same flag), the + same posture the Android side takes toward Firebase Crashlytics. Documented inline in + `CrashReporter.swift` and in the release notes. + +### Feature-parity re-verification + +The v1.9.x host features were re-checked against the **v2.0.0 bridge surface** — the +timebase rewrite changed only the scheduler internals, not the `rustynes-mobile` typed +surface — and all continue to route through the unchanged bridge + the re-ported +save-state epoch: + +| Feature | Bridge / host path | Status vs v2.0.0 | +|---|---|---| +| Game Center cloud saves / achievements | `GameCenterModel` (host, GameKit) | Unchanged — no bridge dependency | +| iCloud KV-store config + CloudKit `.rns` sync | `CloudConfigSync` / `CloudSaveStateSync` over the platform-agnostic `.rns` format | Unchanged — `.rns` epoch re-ported in v2.0.5 | +| MFi / hardware controllers (hot-plug, P1–P4) | `GameControllerManager` → `set_controller` late-latch | Unchanged — same late-latch bitmask | +| ReplayKit capture / share + PiP | `ScreenRecorder` (host) | Unchanged — no bridge dependency | +| Accessibility (VoiceOver, Dynamic Type, colorblind) | SwiftUI host + `AccessibilityPalettes` | Unchanged | + +### Version + +Workspace `2.0.5 → 2.0.6` (version-only `Cargo.lock` cascade); iOS `MARKETING_VERSION → +2.0.6` in `ios/project.yml`. + +## 2. Verification / validation + +### Host gate suite (all green) + +| Gate | Result | +|---|---| +| `cargo check --workspace` (version bump + lock regen) | PASS (version-only lock diff) | +| `cargo fmt --all --check` | PASS | +| `cargo clippy --workspace --all-targets -- -D warnings` | PASS (no issues) | +| `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` | PASS | +| `no_std` thumbv7em cross-compile | PASS | +| both wasm32 clippy gates | PASS | +| `Localizable.xcstrings` JSON validity (EN source + added ES) | PASS | +| `markdownlint` (pinned v0.39.0, changed docs) | PASS | + +### Swift-side discipline (not host-certifiable) + +New Swift **matches the SettingsView's existing SwiftUI baseline** — the file already uses +`NavigationStack`, closure-form `NavigationLink { } label: { }`, and +`ToolbarItem(placement: .topBarTrailing)` (the "Done" button), so the new `CrashLogsView` / +`CrashLogDetailView` follow that same pattern and introduce **no availability regression**. +The crash core (`NSSetUncaughtExceptionHandler` with a non-capturing `@convention(c)` +closure, `UIPasteboard`, `Button(role:)`, `.textSelection`) is iOS-15-safe. + +> **Pre-existing discrepancy flagged for the maintainer (→ v2.0.7):** `ios/project.yml` +> declares `options.deploymentTarget.iOS: "15.0"`, but the pre-existing +> `NavigationStack` (iOS 16) + `.topBarTrailing` (iOS 17) usage across `SettingsView` (and +> other views) already requires a higher floor. v2.0.6 does **not** change the target +> (out of scope, and the new code merely follows the established pattern); reconciling +> `deploymentTarget` / `IPHONEOS_DEPLOYMENT_TARGET` against the real archive target is a +> v2.0.7 polish item. + +New files are auto-included by xcodegen (`sources: path: RustyNES`), so no `project.yml` +source edit is needed. The Swift **compile** + on-device behaviour remain the documented +macOS / TestFlight carryover — there is no Xcode on the Linux host, and the iOS CI job is +tag-gated (§4). + +## 3. On-device / maintainer carryovers (v2.0.9 / the TestFlight upload) + +- Build with **Xcode 26 / the iOS 26 SDK** (the App Store submission floor — a hard gate + at v2.0.7) and confirm the Diagnostics toggle + crash-log viewer render on device. +- Force a test `NSException` on device: confirm a local log is written, listed, readable, + copyable, and pruned to 10; confirm toggling reporting off stops new logs. +- Re-run the v1.9.9 on-device TestFlight checklist (ROM import, save / rewind, MFi + controller, audio interruptions, ProMotion pacing, an accurate privacy label). + +## 4. Carryovers to v2.0.7 → v2.0.8 + +Polish / performance + the **privacy-manifest + required-reason-API audit** + the **Xcode +26 / iOS 26 SDK** submission-floor wiring + the **`deploymentTarget` reconciliation** (the +pre-existing 15.0-vs-actual discrepancy above) land in **v2.0.7**; the App Store Connect +listing + signing scaffold + §4.7 self-audit in the **v2.0.8** iOS RC. Post-Timebase +feature carryovers (FDS/NSF, the 20-band EQ, `.dbg` source maps, a cheats DB, box-art +scraping, a dedicated external-display output) remain deferred for re-evaluation at/after +v2.1.0. Full phasing: `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. diff --git a/docs/ios.md b/docs/ios.md index a40e7fdd6..7595a27d8 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -259,6 +259,19 @@ finalization continues **v2.0.6 → v2.0.8** (feature parity → polish + the ** iOS 26 SDK** App Store submission floor → the iOS RC); full phasing in `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. +**v2.0.6 "Parity" — feature parity + opt-in crash reporting.** v2.0.6 closes the v1.9.9 +iOS-applicable deferral for an **opt-in crash-reporting surface** (`CrashReporter.swift`, +the iOS analogue of the Android v1.8.8 `CrashReporter`): **off by default**, **Settings → +Diagnostics** installs an uncaught-`NSException` handler that writes **local** crash logs +(app version / device / OS / exception / call stack, newest 10 kept) the user can view + +copy in-app — **nothing is uploaded**, so the "Data Not Collected" label + the +`PrivacyInfo.xcprivacy` are unchanged (EN + ES). Pure-Swift signal-based traps stay a +documented maintainer / third-party-SDK option (the same posture as Android's Crashlytics +note), and the handler re-checks the live opt-in at crash time so opting out stops new logs +at once. v2.0.6 also re-verifies the v1.9.x host features (Game Center, CloudKit save sync, +MFi controllers, capture / PiP, accessibility) against the **unchanged v2.0.0 bridge +surface** — recorded in `docs/ios-v2.0.6-readiness.md`. + **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 diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index a5ec3b7e3..3a9f429f8 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -128,6 +128,19 @@ final class AppModel: ObservableObject { } } + /// Opt-in crash reporting (v2.0.6 "Parity"): **off by default**, privacy-first. + /// When on, an uncaught-`NSException` handler writes local crash logs the user can + /// view / copy from Settings; RustyNES uploads nothing, so the "Data Not Collected" + /// privacy label holds. Not cloud-synced — a device-local diagnostic preference. + @Published var crashReportingEnabled: Bool = false { + didSet { + UserDefaults.standard.set(crashReportingEnabled, forKey: CrashReporter.enabledKey) + // Install lazily on opt-in; the handler re-checks the live flag at crash + // time, so opting back out stops new logs without needing to uninstall. + if crashReportingEnabled { CrashReporter.install(enabled: true) } + } + } + /// Persist one shader param: re-apply to the renderer, write UserDefaults, and /// mirror to iCloud (unless we are currently applying a remote change). private func persistParam(_ value: Float, key: String) { @@ -186,6 +199,7 @@ final class AppModel: ObservableObject { } muted = UserDefaults.standard.bool(forKey: "muted") hapticsEnabled = UserDefaults.standard.bool(forKey: "hapticsEnabled") + crashReportingEnabled = UserDefaults.standard.bool(forKey: CrashReporter.enabledKey) globalPaletteId = UserDefaults.standard.string(forKey: "paletteId") ?? "" // Restore the persisted shader params, falling back to the defaults above when // a key was never written (UserDefaults.float returns 0 for a missing key, so @@ -204,6 +218,9 @@ final class AppModel: ObservableObject { // haptics engine to the persisted value explicitly (otherwise a stored // `true` would leave the generator unprepared until the user re-toggles). haptics.isEnabled = hapticsEnabled + // Install the opt-in crash handler at launch if the user previously enabled it + // (a property `didSet` does NOT run for in-init assignment, so install here). + CrashReporter.install(enabled: crashReportingEnabled) isApplyingCloud = false // Reconcile with iCloud at launch: pull any values another device wrote diff --git a/ios/RustyNES/CrashReporter.swift b/ios/RustyNES/CrashReporter.swift new file mode 100644 index 000000000..c15a07c22 --- /dev/null +++ b/ios/RustyNES/CrashReporter.swift @@ -0,0 +1,176 @@ +// +// CrashReporter.swift +// +// Opt-in, privacy-first crash reporting (v2.0.6 "Parity") — the iOS analogue of +// the Android `CrashReporter` (Android v1.8.8 "Atlas"). Closes the v1.9.9 +// readiness gap that listed an "opt-in crash-reporting surface" as an +// iOS-applicable deferral. +// +// Privacy posture: **off by default** (explicit consent via the Settings toggle), +// so the app's "Data Not Collected" privacy nutrition label is preserved. When the +// user opts in, an uncaught-exception handler writes the stack trace to a **local** +// file in the app sandbox that the user can read / copy — RustyNES uploads nothing +// on its own, so `PrivacyInfo.xcprivacy` is unchanged. +// +// Crash-handler discipline (why everything is pre-resolved at install time). The +// uncaught-exception handler runs on an **arbitrary thread in an unstable process** +// (potential heap corruption / deadlock), so it must touch as little as possible: +// - It reads only **cached, plain-value statics** (device / OS / app version) and a +// **pre-resolved log-directory URL** — never `UIDevice.current` (which is +// `@MainActor`-isolated and would be an actor-isolation violation from the C +// callback) and never `Bundle.main.infoDictionary` / directory creation. +// - Log **pruning** (directory enumeration + file deletion) is done at +// `install(enabled:)` (launch, main thread), NOT inside the handler, so a crash +// never triggers risky filesystem walks that could cause a secondary crash before +// the current report is written. +// The handler therefore does the bare minimum: format one string and write one file. +// +// Honesty note (what this catches, and what it does not). `NSSetUncaughtException` +// `Handler` catches Objective-C / bridged `NSException` crashes. Pure-Swift runtime +// traps (`fatalError`, force-unwrap of nil, out-of-bounds, integer overflow) abort +// via a POSIX signal (SIGTRAP / SIGILL / SIGABRT), which an exception handler does +// NOT see. Installing async-signal-unsafe file writes from a signal handler is +// unsound, so — exactly as the Android side documents Firebase Crashlytics as the +// maintainer option rather than pulling it in — Swift-trap capture is left to a +// real third-party reporter a maintainer can add later (gated on this same opt-in +// flag). This surface diagnoses the ObjC/`NSException` class (UIKit, KVO, bridged +// Foundation) without a third-party SDK and without collecting anything by default. +// + +import Foundation +import UIKit + +/// Opt-in, local-only crash reporting. All members are static — there is one +/// process-wide handler, mirroring the Android `object CrashReporter`. +enum CrashReporter { + /// UserDefaults key for the opt-in flag (shared with `AppModel`). + static let enabledKey = "crashReportingEnabled" + + private static let dirName = "crash-logs" + private static let maxLogs = 10 + + /// Set once we chain the handler, so a runtime toggle doesn't double-install. + private static var installed = false + + /// The handler that was installed before ours, chained after we record. Held as + /// **static** (not captured) because `NSSetUncaughtExceptionHandler` takes a + /// `@convention(c)` callback, which cannot capture local context — the closure + /// below may only reference global / static state. + private static var previousHandler: (@convention(c) (NSException) -> Void)? + + // Metadata cached at `install(enabled:)` on the main thread, so the crash handler + // (arbitrary thread, unstable process) reads only plain values — never the + // `@MainActor` `UIDevice.current` or `Bundle.main.infoDictionary`. + private static var deviceModel = "Unknown" + private static var systemVersion = "Unknown" + private static var appVersion = "?" + private static var appBuild = "?" + /// The pre-resolved crash-log directory, so the handler does no URL resolution or + /// directory creation. `nil` until `install(enabled:)` runs. + private static var cachedLogDir: URL? + + /// Install the opt-in uncaught-`NSException` handler. A no-op unless `enabled` + /// (the user's Settings opt-in) and never installs twice. `@MainActor` because it + /// reads `UIDevice.current` (main-actor-isolated) while caching metadata; it is + /// only ever called from the `@MainActor` `AppModel`. Chains to any previous + /// handler so the normal crash / process kill still happens after we record; the + /// handler re-checks the live opt-in flag at crash time, so toggling reporting off + /// stops new logs immediately (without needing to uninstall). + @MainActor + static func install(enabled: Bool) { + guard enabled, !installed else { return } + installed = true + + // Resolve + cache everything the handler might need, now, on the main thread. + deviceModel = UIDevice.current.model + systemVersion = UIDevice.current.systemVersion + appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" + cachedLogDir = resolveLogDir() + // Prune here (launch), NOT inside the crash handler — directory enumeration + + // deletion is unsafe in an unstable post-crash process. + pruneOldLogs() + + previousHandler = NSGetUncaughtExceptionHandler() + // The closure captures NOTHING (only static references), so it is convertible + // to the `@convention(c)` handler `NSSetUncaughtExceptionHandler` expects. + NSSetUncaughtExceptionHandler { exception in + // Re-check the live flag: honour a runtime opt-out. + if UserDefaults.standard.bool(forKey: CrashReporter.enabledKey) { + CrashReporter.writeLog(exception) + } + CrashReporter.previousHandler?(exception) + } + } + + /// Resolve the crash-log directory (`Application Support/RustyNES/crash-logs/`), + /// creating it. Called at install time and from the (main-thread) Settings viewer; + /// **never** from the crash handler, which uses the pre-resolved `cachedLogDir`. + private static func resolveLogDir() -> URL { + var base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("RustyNES", isDirectory: true) + .appendingPathComponent(dirName, isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + // Keep the crash logs out of iCloud / device backups — they are ephemeral local + // diagnostics, so excluding them preserves the "nothing leaves the device" + // posture even through a full device backup / restore. + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? base.setResourceValues(values) + return base + } + + /// Serialize one exception to a timestamped local file. Runs from the C exception + /// handler on an arbitrary thread, so it touches only cached statics + the + /// pre-resolved directory and does the minimum work: format one string, write one + /// file. No pruning, no `UIDevice`, no directory creation here. + private static func writeLog(_ exception: NSException) { + guard let dir = cachedLogDir else { return } + let stamp = timestamp() + var body = "RustyNES crash report\n" + body += "Time: \(stamp)\n" + body += "Version: \(appVersion) (\(appBuild))\n" + body += "Device: \(deviceModel) — iOS \(systemVersion)\n" + body += "Name: \(exception.name.rawValue)\n" + body += "Reason: \(exception.reason ?? "(none)")\n\n" + body += exception.callStackSymbols.joined(separator: "\n") + let url = dir.appendingPathComponent("crash-\(stamp).txt") + try? body.write(to: url, atomically: true, encoding: .utf8) + } + + /// `yyyyMMdd-HHmmss` in a fixed (US-POSIX) locale so filenames sort chronologically. + private static func timestamp() -> String { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "yyyyMMdd-HHmmss" + return fmt.string(from: Date()) + } + + /// Keep only the most recent `maxLogs` files (newest-first by filename). Called at + /// install time (main thread), never from the crash handler. + private static func pruneOldLogs() { + let logs = savedLogs() + guard logs.count > maxLogs else { return } + for url in logs.dropFirst(maxLogs) { + try? FileManager.default.removeItem(at: url) + } + } + + /// The saved crash-log files, newest first (for the Settings viewer). Main-thread / + /// launch use only; resolves the directory lazily if `install` has not run. + static func savedLogs() -> [URL] { + let dir = cachedLogDir ?? resolveLogDir() + let contents = (try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil)) ?? [] + return contents + .filter { $0.lastPathComponent.hasPrefix("crash-") } + .sorted { $0.lastPathComponent > $1.lastPathComponent } + } + + /// Delete every saved crash log (the Settings "clear" action). + static func clear() { + for url in savedLogs() { + try? FileManager.default.removeItem(at: url) + } + } +} diff --git a/ios/RustyNES/Localizable.xcstrings b/ios/RustyNES/Localizable.xcstrings index da94ebf64..4eca94058 100644 --- a/ios/RustyNES/Localizable.xcstrings +++ b/ios/RustyNES/Localizable.xcstrings @@ -2,6 +2,41 @@ "sourceLanguage" : "en", "version" : "1.0", "strings" : { + "(unreadable)" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "(ilegible)" } } + } + }, + "Copy" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Copiar" } } + } + }, + "Crash logs" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Registros de fallos" } } + } + }, + "Crash reporting" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Informes de fallos" } } + } + }, + "Diagnostics" : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Diagnósticos" } } + } + }, + "No crash logs." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "No hay registros de fallos." } } + } + }, + "Off by default. When on, uncaught-exception reports are written to a local file you can read and copy — nothing is uploaded." : { + "localizations" : { + "es" : { "stringUnit" : { "state" : "translated", "value" : "Desactivado de forma predeterminada. Cuando está activado, los informes de excepciones no detectadas se escriben en un archivo local que puedes leer y copiar; no se sube nada." } } + } + }, "This movie was recorded on a pre-v2.0.0 build. Input replay proceeds, but exact framebuffer/audio reproduction is not guaranteed across the engine-timebase change (ADR 0028)." : { "localizations" : { "es" : { "stringUnit" : { "state" : "translated", "value" : "Esta repetición se grabó en una versión anterior a la v2.0.0. La reproducción de entradas continúa, pero no se garantiza la reproducción exacta del fotograma o del audio a través del cambio de base de tiempo del motor (ADR 0028)." } } diff --git a/ios/RustyNES/SettingsView.swift b/ios/RustyNES/SettingsView.swift index f85ba31b1..7866bf7ff 100644 --- a/ios/RustyNES/SettingsView.swift +++ b/ios/RustyNES/SettingsView.swift @@ -9,6 +9,7 @@ // import SwiftUI +import UIKit import UniformTypeIdentifiers struct SettingsView: View { @@ -141,6 +142,20 @@ struct SettingsView: View { } } + // Opt-in crash reporting (v2.0.6 "Parity"). Off by default; local-only. + Section { + Toggle("Crash reporting", isOn: $model.crashReportingEnabled) + NavigationLink { + CrashLogsView() + } label: { + Label("Crash logs", systemImage: "ladybug") + } + } header: { + Text("Diagnostics") + } footer: { + Text("Off by default. When on, uncaught-exception reports are written to a local file you can read and copy — nothing is uploaded.") + } + Section { NavigationLink { AboutView() @@ -743,3 +758,76 @@ struct GameSettingsView: View { ) } } + +// MARK: - Crash logs (v2.0.6 "Parity") + +/// A read-only viewer for the opt-in crash logs: list newest-first, tap to read the +/// full trace and copy it, or clear them all. Local-only — nothing is uploaded (the +/// logs are written by `CrashReporter` only when the user has opted in). +private struct CrashLogsView: View { + @State private var logs: [URL] = CrashReporter.savedLogs() + + var body: some View { + List { + if logs.isEmpty { + Text("No crash logs.") + .foregroundStyle(.secondary) + } else { + ForEach(logs, id: \.self) { url in + NavigationLink { + CrashLogDetailView(url: url) + } label: { + Text(url.deletingPathExtension().lastPathComponent) + .font(.system(.body, design: .monospaced)) + } + } + } + } + .navigationTitle("Crash logs") + .toolbar { + if !logs.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button("Clear", role: .destructive) { + CrashReporter.clear() + logs = [] + } + } + } + } + } +} + +/// The full text of one crash log, with a copy-to-clipboard action. +private struct CrashLogDetailView: View { + let url: URL + // Loaded once off the main thread in `.task` (below) rather than read + // synchronously in `body`, so re-rendering never blocks the main thread on disk. + @State private var text = "" + + var body: some View { + ScrollView { + Text(text) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle(url.deletingPathExtension().lastPathComponent) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIPasteboard.general.string = text + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + .task { + let target = url + text = await Task.detached { + (try? String(contentsOf: target, encoding: .utf8)) ?? String(localized: "(unreadable)") + }.value + } + } +} diff --git a/ios/project.yml b/ios/project.yml index 7b7448391..1a03055dc 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -38,10 +38,10 @@ settings: # The app is iPhone + iPad (device family 1,2 = Universal). TARGETED_DEVICE_FAMILY: "1,2" # Keep in step with the workspace version (CHANGELOG / Cargo `[workspace.package]`). - # v2.0.5 "Landfall" — the first iOS finalization release onto the v2.0.0 - # "Timebase" core; realigned from the frozen v1.9.x train (it was never bumped - # past the v1.9.1 checked-in default, since fastlane injected the real number). - MARKETING_VERSION: "2.0.5" + # Realigned to the workspace version at v2.0.5 "Landfall" (the frozen v1.9.x train + # never bumped past the v1.9.1 checked-in default, since fastlane injected the real + # number); advanced to 2.0.6 "Parity" for the iOS feature-parity finalization. + MARKETING_VERSION: "2.0.6" # The build (not marketing) number. Each TestFlight upload needs a UNIQUE one; # the fastlane `beta` lane overrides this with the CI run number at build time # (xcargs CURRENT_PROJECT_VERSION), so this checked-in "1" is only the local / diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index a39ae3ea4..3a3380671 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -46,8 +46,9 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. ## Status -- **Current release:** **RustyNES v2.0.5 "Harbor"** (2026-07-09) — the fifth release of the **v2.0.x mobile-finalization train** and the **first iOS finalization release** ("Landfall"), opening the iOS window (**v2.0.5 → v2.0.8**) that mirrors the Android v2.0.1 → v2.0.4 window. A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.4** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It re-ports the frozen v1.9.9 SwiftUI / Metal app onto the v2.0.0 "Timebase" core: **(1)** the **pre-Timebase movie warning surfaced + localized on iOS** — a non-blocking notice on its own channel (multiplexed through a single alert that prefers an error when both are queued, **EN + ES**, drained via `EmulatorCore.drainWarnings()` → `NesController.drainWarningCodes()`, wording byte-identical to the Android v2.0.4 string) so loading a pre-v2.0.0 `.rnm` tells the user byte-exact framebuffer/audio reproduction isn't guaranteed across the ADR-0028 timebase change; **(2)** the **UniFFI-Swift binding surface re-confirmed** against the v2.0.0 bridge (`drainWarningCodes` / `HostWarning.preTimebaseMovie` / `moviePlay`, host-verified Swift emit); and the **version bump** (workspace `2.0.4 → 2.0.5`; iOS `MARKETING_VERSION 1.9.1 → 2.0.5`, realigned from the frozen v1.9.x default). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); the on-device closeout — the xcframework build on macOS (**Xcode 26 / iOS 26 SDK**), save-state migration from a v1.9.x install, and the AccuracyCoin / SMB / Zelda determinism smoke on Apple silicon — is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.5]` + `docs/ios-v2.0.5-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. -- **Preceding release:** **RustyNES v2.0.4 "Harbor"** (2026-07-08) — the fourth release of the **v2.0.x mobile-finalization train** and the **Android release-candidate** milestone. A **host / Android-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.3** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It stages the RC scaffolding a maintainer needs to upload the Android app to a Play Console testing track: the `release` build type wired to the upload keystore with a **graceful debug-signing fallback** (keyless CI / local `assemble{Foss,Play}Release` still produces an installable — debug-signed, never shippable — RC artifact); debug-only **StrictMode** diagnostics (`DebugStrictMode`, thread + VM, log-only, `BuildConfig.DEBUG`-guarded, inert in release) as the host complement to the on-device crash-free-rate / ANR gate; version-controlled **fastlane Play Console listing metadata** (`fastlane/metadata/android/{en-US,es-ES}/`); an **R8/ProGuard final hardening review** (keep set confirmed complete, none loosened); and the **version bump** (workspace `2.0.3 → 2.0.4`; Android `versionCode 20003 → 20004` / `versionName → 2.0.4`). The `foss` flavor stays **behaviour-identical**. **No store submission** (that is v2.1.0); the on-device closeout — real-keystore signing, internal/closed testing track, crash-free-rate + ANR gate on hardware, live monetization runtime, the deferred per-feature gate migration — is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.4]` + `to-dos/plans/v2.0.4-android-rc-plan.md`. +- **Current release:** **RustyNES v2.0.6 "Harbor"** (2026-07-09) — the sixth release of the **v2.0.x mobile-finalization train** and the **second iOS finalization release** ("Parity"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.5** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It adds a **new opt-in, privacy-first crash-reporting surface** (off by default — the iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 iOS-applicable deferral): **Settings → Diagnostics** installs an uncaught-`NSException` handler that writes **local** crash logs the user can view + copy in-app — **nothing is uploaded**, so the "Data Not Collected" privacy label is unchanged (EN + ES); the handler re-checks the live opt-in at crash time so opting out stops new logs immediately. It also records the **feature-parity re-verification** of the v1.9.x host features (Game Center, CloudKit save sync, MFi controllers, capture / PiP, accessibility) against the unchanged v2.0.0 bridge surface. Version bump (workspace `2.0.5 → 2.0.6`; iOS `MARKETING_VERSION → 2.0.6`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device crash-capture verification is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.6]` + `docs/ios-v2.0.6-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. +- **Preceding release:** **RustyNES v2.0.5 "Harbor"** (2026-07-09) — the fifth release of the **v2.0.x mobile-finalization train** and the **first iOS finalization release** ("Landfall"), opening the iOS window (**v2.0.5 → v2.0.8**) that mirrors the Android v2.0.1 → v2.0.4 window. A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.4** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It re-ports the frozen v1.9.9 SwiftUI / Metal app onto the v2.0.0 "Timebase" core: **(1)** the **pre-Timebase movie warning surfaced + localized on iOS** — a non-blocking notice on its own channel (multiplexed through a single alert that prefers an error when both are queued, **EN + ES**, drained via `EmulatorCore.drainWarnings()` → `NesController.drainWarningCodes()`, wording byte-identical to the Android v2.0.4 string) so loading a pre-v2.0.0 `.rnm` tells the user byte-exact framebuffer/audio reproduction isn't guaranteed across the ADR-0028 timebase change; **(2)** the **UniFFI-Swift binding surface re-confirmed** against the v2.0.0 bridge (`drainWarningCodes` / `HostWarning.preTimebaseMovie` / `moviePlay`, host-verified Swift emit); and the **version bump** (workspace `2.0.4 → 2.0.5`; iOS `MARKETING_VERSION 1.9.1 → 2.0.5`, realigned from the frozen v1.9.x default). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); the on-device closeout — the xcframework build on macOS (**Xcode 26 / iOS 26 SDK**), save-state migration from a v1.9.x install, and the AccuracyCoin / SMB / Zelda determinism smoke on Apple silicon — is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.5]` + `docs/ios-v2.0.5-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. +- **Earlier in the train:** **RustyNES v2.0.4 "Harbor"** (2026-07-08) — the fourth release of the **v2.0.x mobile-finalization train** and the **Android release-candidate** milestone. A **host / Android-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.3** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It stages the RC scaffolding a maintainer needs to upload the Android app to a Play Console testing track: the `release` build type wired to the upload keystore with a **graceful debug-signing fallback** (keyless CI / local `assemble{Foss,Play}Release` still produces an installable — debug-signed, never shippable — RC artifact); debug-only **StrictMode** diagnostics (`DebugStrictMode`, thread + VM, log-only, `BuildConfig.DEBUG`-guarded, inert in release) as the host complement to the on-device crash-free-rate / ANR gate; version-controlled **fastlane Play Console listing metadata** (`fastlane/metadata/android/{en-US,es-ES}/`); an **R8/ProGuard final hardening review** (keep set confirmed complete, none loosened); and the **version bump** (workspace `2.0.3 → 2.0.4`; Android `versionCode 20003 → 20004` / `versionName → 2.0.4`). The `foss` flavor stays **behaviour-identical**. **No store submission** (that is v2.1.0); the on-device closeout — real-keystore signing, internal/closed testing track, crash-free-rate + ANR gate on hardware, live monetization runtime, the deferred per-feature gate migration — is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.4]` + `to-dos/plans/v2.0.4-android-rc-plan.md`. - **Earlier in the train:** **RustyNES v2.0.3 "Harbor"** (2026-07-08) — the third release of the **v2.0.x mobile-finalization train** and the one that makes the octal-latch accuracy work real at the shipped default. The **2-cycle-ALE PPU fetch model is promoted from the experimental `mc-ppu-2cycle-ale` flag to the unconditional, only PPU fetch path** (ADR 0030), so the shipped default now scores **AccuracyCoin 141/141 (100.00%, RAM-authoritative)** — both **"ALE + Read"** (`$0491`) and **"Hybrid Addresses"** (`$0492`) pass out of the box (previously an honest 139/141). This is the genuine two-dot fetch (even-dot ALE-drive + `octal_latch` load; odd-dot `(address & 0x3F00) | octal_latch` splice + read) where the latch *naturally* carries the stale byte (`copy_v_delay = 4` → NT splice `$2F19` for Hybrid; `$2007`-ALE overlap freeze → `$0FFF` for ALE+Read), replacing v2.0.2's whole-dot `+1 coarse-X` stand-in. **Both experiment flags retired** (`mc-ppu-2cycle-ale` + `mc-ppu-bus-addr-hybrid`); stand-in code deleted; `octal_trace` survives behind the new default-off `ppu-octal-trace`. Verified: **60-ROM oracle 60/60** with two documented re-blesses (SMB3, Uchuu Keibitai SDF — single-tile `$2006`-during-render shifts, more TriCNES-faithful, audio/cycle byte-identical), nestest 0-diff, mmc3 18/18, `ppu_sprites` 19/19; ~10% headless frame-cost rise (~4.15 ms/frame). **Save-state:** additive **`PPU_SNAPSHOT_VERSION` 4 → 5** tail (netplay-rollback determinism; pre-v5 `.rns` still load; forward-incompatible with ≤v2.0.2 but not an ADR-0028 epoch break). Also: the **Harbor Android foss/play monetization glue** (step 5 — AppLovin MAX + RevenueCat 8.10.0 `MonetizationGate`, gating/paywall/session/progress; no-op `foss` twin; both flavors assemble, dormant pending v2.0.9 on-device verify) + a **host-localizable mobile bridge-warning** API (`HostWarning` enum + `drain_warning_codes()`). See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.3]` + `to-dos/plans/v2.0.3-2cycle-ale-plan.md`. - **Earlier in the train:** **RustyNES v2.0.2 "Harbor"** (2026-07-08) — the second release of the **v2.0.x mobile-finalization train** and Harbor's **headline accuracy release**: the two new upstream AccuracyCoin PPU tests v2.0.1 documented as honest gaps — **"ALE + Read"** (`$0491`) and **"Hybrid Addresses"** (`$0492`) — are now **solved flag-on** by a whole-dot port of TriCNES's **octal-latch multiplexed-bus PPU model** (ADR 0030, commit `27c103c`), behind the pre-existing default-off `mc-ppu-bus-addr-hybrid` flag. **Shipped default stays honest 139/141 (98.58%), byte-identical to v2.0.1; flag-on the same build is verified 141/141 (100.00%)** (framebuffer 100%, nestest 0-diff, mmc3 A12 + IRQ all pass, `ppu_sprites` 19/19). The campaign corrected two ADR 0030 premises — **Mesen2 does NOT pass these tests** (both bytes `0x0A`; the correct oracle is TriCNES, the AccuracyCoin author's own MIT emulator, `ref-proj/TriCNES` commit `9199870`), and **a whole-dot port suffices** (the full 2-cycle-ALE refactor was not required). Per the maintainer's **refine-then-promote** decision (ADR 0030), the flag ships **default-off** in v2.0.2 and is **promoted to default (shipped 141/141) in v2.0.3** — after the Hybrid `+1 coarse-X` approximation is reworked to a first-principles latch-carry model and gated on the 60-ROM commercial byte-identity oracle. No snapshot-format bump (`PPU_SNAPSHOT_VERSION` stays 4). **This release does not claim the shipped build is 141/141, nor that the flag is promoted.** See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.2]` + `to-dos/plans/v2.0.2-harbor-plan.md`. - **Earlier in the train:** **RustyNES v2.0.1 "Harbor"** (2026-07-08) — the first release of the **v2.0.x mobile-finalization train** on the v2.0.0 "Timebase" core: the Android core re-port + `foss`/`play` flavor-split scaffolding (ADR 0025), the **AccuracyCoin oracle re-sync** (catalog 144→146 rows / 139→141 assigned; measured honestly at **139/141, 98.58%** — the two new upstream PPU tests "ALE + Read" / "Hybrid Addresses" documented as gaps, then solved flag-on in v2.0.2 per ADR 0030), the **CI cost optimization** (heavy suite gated to `release/*` + a weekly cron), the **dependency sweep** (uniffi 0.32 / mlua 0.12 / wgpu-naga 29.0.4 / cc 1.2.66; wgpu 30 deferred on the egui 0.35 pin), and the **`mc-r1-dmc-abort-probe` housekeeping removal**. Every core change is behaviour-neutral, so the deterministic core is byte-identical to v2.0.0: the **139 passing** AccuracyCoin tests and nestest 0-diff are unchanged — only the *denominator* grew (139→141) as the oracle re-sync added the two new upstream PPU tests. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.1]` + `to-dos/plans/v2.0.1-harbor-plan.md`.