Repository navigation
feat(android)!: v2.0.1 core re-port onto Timebase + foss/play flavor-split scaffolding - #235
Conversation
…split scaffolding
Cut the first release of the Android mobile-finalization re-port train (v2.0.1 -> v2.0.4)
onto the v2.0.0 "Timebase" core, and land the STRUCTURAL start of the foss/play product-
flavor split (ADR 0025). Every change here is host/Android-side only: the deterministic
chip stack (rustynes-core/-cpu/-ppu/-apu/-mappers) is byte-identical, so host CI accuracy
is unchanged by construction (AccuracyCoin 139/141).
Version bump
------------
android/app: versionCode 20000 -> 20001, versionName "2.0.0" -> "2.0.1".
Movie-epoch warning on the mobile bridge (ADR 0028)
---------------------------------------------------
rustynes-mobile's NesController::movie_play now peeks
rustynes_core::recorded_before_v2_timebase(&bytes) and, for a pre-v2.0.0 "Timebase" .rnm,
queues a drainable host warning via the new NesController::drain_warnings() -- mirroring
the desktop + wasm frontends' identical notice. A pre-v2.0.0 movie still replays its
recorded INPUT faithfully, but the host can now tell the user that exact framebuffer/audio
reproduction is not guaranteed across the engine-timebase boundary (the one-clock /
every-cycle-bus-access rewrite changed the sub-frame timing the old movie was captured
against). A malformed/short header is treated as "not pre-v2" (deserialize already
succeeded, so it is a current-epoch movie). A pre-v2.0.0 .rns restore already maps to a
clean MobileError::SaveState (the core's restore rejects the bumped FORMAT_VERSION) -- no
crash, no code change needed. Pure host-shell wiring: a new `warnings: Vec<String>` field
on Inner, initialised in `new`, cleared on `load_rom`, drained by `drain_warnings`; the
deterministic core never reads it. Extended the fm2 replay test to assert a current-epoch
.rnm raises no warning.
foss/play product-flavor split -- STRUCTURAL start (ADR 0025)
------------------------------------------------------------
The Android build gains a `distribution` flavor dimension so RustyNES can eventually ship
a genuinely Google-free, ad-free, tracking-free artifact to F-Droid (a BuildConfig flag
gates code EXECUTION, not manifest merging / packaging -- only a product flavor produces a
truly clean build). This commit does the structural work; the monetization ad glue
(AppLovin MAX + RevenueCat) stays dormant and is the v2.1.0 step.
- Flavor dimension: `foss` (isDefault = true) + `play`, with PLAY_BUILD set per-flavor
(false/true) instead of in defaultConfig. The per-feature runtime gates (PGS_ENABLED,
CHROMECAST_ENABLED, PLAY_INTEGRITY_ENABLED, ...) stay in defaultConfig as the in-`play`
toggles the maintainer flips at launch.
- Every proprietary Google-Play SDK moved from unconditional implementation(...) to
playImplementation(...): Play Billing, the Cast Application Framework, Play Games v2,
Play Integrity, and in-app update/review. The `foss` variant links NONE of them.
- Façade: the six Google-touching glue classes moved to src/play/ (LicenseManager,
PlayGamesManager, CloudSaveManager, IntegrityManager, PlayUpdatesManager,
ChromecastSender + RustyNesCastOptionsProvider), each with a byte-compatible no-op
twin in src/foss/ that carries the identical public surface and imports no com.google.*
/ com.android.billingclient.*. Shared pure types (PgsIds, IntegrityVerdict,
DEMO_SESSION_SECONDS) moved to a new src/main/PlayFacadeShared.kt so MainActivity
resolves them in both flavors. MainActivity now reads a flavor-neutral
LicenseManager.priceLabel (a String) instead of the Google ProductDetails type, so
src/main links no Play SDK -- the only Google-typed leak into the shared shell. The
AOSP Presentation-API CastManager (Cast.kt) is NOT proprietary and stays in src/main.
- Manifest: the Cast-framework OPTIONS_PROVIDER and Play-Games APP_ID meta-data moved to
a new src/play/AndroidManifest.xml overlay so they never merge into the `foss` manifest
(the future AD_ID permission + AdMob/AppLovin entries land there too, never in main).
- installDebug -> installFossDebug alias (afterEvaluate, guarded against the AGP anchor)
so the existing dev/on-device command is undisturbed; the aggregate assembleDebug /
bundleRelease anchors AGP still creates are left as-is.
- baselineprofile (com.android.test module targeting the now-flavored :app) gains
missingDimensionStrategy("distribution", "foss") so variant-matching / Gradle sync
resolve unambiguously.
- android.yml now bundles BOTH flavors (bundleFossRelease + bundlePlayRelease) and its
artifact glob widened to outputs/bundle/*/*.aab.
Docs
----
- Created to-dos/v1.8.x-on-device-verification.md (the manual on-device checklist the
mobile-finalization plan repeatedly cites but which did not exist): ROM import,
save-state/rewind, MFi/hardware controllers, audio + interruptions, SMB/Zelda
determinism smoke, the pre-v2.0.0 .rns/.rnm handling, the accuracy figure to match
(139/141), and the both-flavor verification gate.
- docs/android.md: flavored build commands + the "structural start landed in v2.0.1"
note. docs/STATUS.md: an in-progress v2.0.1 banner. CHANGELOG [Unreleased]: the full
entry.
The `!` marks the save-state/movie epoch boundary this train rides on (ADR 0028): a
pre-v2.0.0 .rnm now surfaces a warning and a pre-v2.0.0 .rns fails cleanly on mobile,
matching the desktop/wasm behaviour.
Verified locally: cargo fmt --all --check, cargo clippy -p rustynes-mobile --all-targets
-D warnings, cargo check -p rustynes-mobile -p rustynes-monetization, cargo check
--workspace, cargo test -p rustynes-mobile (the movie/drain_warnings test), and
markdownlint on every touched .md -- all green. The Android NDK cross-build + both-flavor
Gradle packaging is deferred to android.yml CI (no local NDK/emulator), per the plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request initiates the v2.0.1 Android core re-port onto the 'Timebase' core and establishes the structural foundation for the foss/play product-flavor split. It introduces the distribution flavor dimension in Gradle, moves proprietary Google Play SDKs to playImplementation, adds no-op façades for the FOSS variant, and refactors the main codebase to use flavor-neutral interfaces. Additionally, it implements a movie-epoch warning system in the Rust mobile bridge to alert users when playing pre-v2.0.0 .rnm movies. Feedback on the changes highlights two issues: first, the FOSS SaveConflict implementation incorrectly uses referential equality instead of structural equality for its byte arrays; second, hardcoding English warning strings in the Rust UniFFI bridge violates the repository's localization guidelines, and should be replaced with localization keys.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
This PR begins the Android v2.0.1 “mobile-finalization” re-port onto the v2.0.0 Timebase core and lays the structural groundwork for a foss/play distribution flavor split, while adding host-drainable warnings to the rustynes-mobile bridge for pre-Timebase .rnm movie playback.
Changes:
- Android: bump to
versionCode=20001/versionName=2.0.1and introduce adistributionflavor dimension (fossdefault,play), scoping proprietary Google SDK deps + glue intoplay. - Mobile bridge: add a drainable warnings queue (
NesController::drain_warnings) and queue a warning when playing pre-v2.0.0.rnmmovies. - CI/docs: update
android.ymlto bundle both flavor AABs and refresh STATUS/Android docs + changelog and on-device verification checklist.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| to-dos/v1.8.x-on-device-verification.md | Adds a manual Android on-device verification checklist, including dual-flavor checks. |
| docs/STATUS.md | Adds an “in progress v2.0.1” status banner describing the Android re-port + flavor split + warning behavior. |
| docs/android.md | Updates Gradle commands and documents the new distribution flavor dimension. |
| crates/rustynes-mobile/src/lib.rs | Adds drainable warnings on the mobile controller and queues a warning for pre-Timebase movie playback; adds a test assertion. |
| CHANGELOG.md | Expands [Unreleased] with v2.0.1 Android + mobile-bridge warning + flavor split notes. |
| android/baselineprofile/build.gradle.kts | Pins missingDimensionStrategy("distribution", "foss") so the baselineprofile module resolves the app variant. |
| android/app/src/play/java/com/doublegate/rustynes/PlayUpdates.kt | Play-flavor implementation header comment for Play Updates/Review manager. |
| android/app/src/play/java/com/doublegate/rustynes/PlayGames.kt | Moves shared IDs out; play-flavor Play Games manager remains here. |
| android/app/src/play/java/com/doublegate/rustynes/Integrity.kt | Moves shared verdict enum out; play-flavor Integrity manager remains here. |
| android/app/src/play/java/com/doublegate/rustynes/CloudSave.kt | Play-flavor cloud save via PGS Snapshots; retains SaveConflict with internal Snapshot handle. |
| android/app/src/play/java/com/doublegate/rustynes/ChromecastSender.kt | Play-flavor Cast Application Framework sender implementation header comment. |
| android/app/src/play/java/com/doublegate/rustynes/Billing.kt | Play-flavor Billing-backed LicenseManager and adds a flavor-neutral priceLabel façade. |
| android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt | New shared pure-Kotlin/AOSP declarations used by both flavors (demo length, PGS IDs, integrity verdict). |
| android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt | Switches UI to read LicenseManager.priceLabel instead of Billing ProductDetails types. |
| android/app/src/main/AndroidManifest.xml | Removes Play Services meta-data from src/main (now play-only manifest). |
| android/app/src/foss/java/com/doublegate/rustynes/PlayUpdates.kt | Adds foss no-op stand-in for Play Updates/Review manager. |
| android/app/src/foss/java/com/doublegate/rustynes/PlayGames.kt | Adds foss no-op stand-in for Play Games manager. |
| android/app/src/foss/java/com/doublegate/rustynes/Integrity.kt | Adds foss no-op stand-in for Play Integrity manager. |
| android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt | Adds foss no-op stand-in for cloud saves + foss SaveConflict type. |
| android/app/src/foss/java/com/doublegate/rustynes/ChromecastSender.kt | Adds foss no-op stand-in for CAF sender. |
| android/app/src/foss/java/com/doublegate/rustynes/Billing.kt | Adds foss no-op stand-in LicenseManager (always unlocked) exposing the same surface. |
| android/app/build.gradle.kts | Introduces the distribution flavors and moves proprietary deps to playImplementation; adds an installDebug alias task. |
| .github/workflows/android.yml | Updates packaging job to build/upload both foss + play release AABs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Resolves the four open PR #235 review threads and the one hard failure the foss/play flavor split introduced in the best-effort "Gradle bundle foss+play release" CI job. Gradle Kotlin-DSL flavor accessors (the CI failure) --------------------------------------------------- The `distribution` flavor dimension (`foss` default + `play`, ADR 0025) moved the six proprietary Google-Play SDKs (Billing, Cast, Play Games v2, Integrity, in-app update/review) onto a `playImplementation(...)` configuration. Kotlin DSL does NOT generate a type-safe accessor for a configuration created by a flavor declared in the *same* build script, so all six calls failed with "Unresolved reference 'playImplementation'" (8 errors), and because script compilation aborted, the `sourceSets["main"].kotlin`/`.jniLibs` accessors on line 181/182 also failed to resolve -- which is why Gradle anchored the whole failure at line 181 even though the root cause was 180 lines lower. Fixed by switching to the documented string-invocation form `"playImplementation"(...)`, which resolves the flavor configuration by name at configuration time rather than via a same-script-generated accessor. The `.kotlin`/`.jniLibs` accessors then compile exactly as they did on `main`. CloudSave.kt SaveConflict equality (gemini + copilot, foss:32-33) ----------------------------------------------------------------- The foss `SaveConflict` overrode `equals`/`hashCode` with referential equality (`this === other` / `System.identityHashCode`) while its own doc-comment claimed structural intent and the shared conflict-picker UI treats the type by value. Replaced with genuine structural equality: value comparison on `conflictId`/`sha`/`slot` plus `contentEquals`/`contentHashCode` over the two `ByteArray` fields (the standard Kotlin idiom for a data class with array members). Two SaveConflicts carrying equal values now compare equal across both flavors. rustynes-mobile pre-Timebase movie warning (copilot, lib.rs:927,2436) --------------------------------------------------------------------- - `movie_play` now reads the pre-v2 epoch via `is_ok_and(|v| v)` instead of `unwrap_or(false)` -- semantically identical (both map `Err` -> `false`) but it reads as "only the Ok(true) case matters." The `Err` arm remains effectively unreachable: `Movie::deserialize` on the line above already validated the magic and the `format_version <= MOVIE_FORMAT_VERSION` gate on the same buffer, and treating any residual header re-read failure as current-epoch (no warning) is the safe, documented default that never blocks playback. - Added `pre_v2_timebase_movie_raises_one_drainable_warning`: it transcodes a valid current-epoch `.rnm`, rewrites only the 2-byte LE `format_version` field 2 -> 1 (the post-version layout is byte-identical across the epoch bump, so it still deserializes cleanly), plays it, and asserts exactly one queued warning citing ADR 0028, plus that a second `drain_warnings()` is empty (drains, no re-emit). Complements the existing current-epoch no-warning assertion. i18n of bridge warnings (gemini, lib.rs:940) -- deferred with rationale ----------------------------------------------------------------------- The bridge returns a pre-composed English advisory string, mirroring the desktop/wasm frontends' identical text (parity is the current contract). The correct end state is a host-localizable warning code/enum resolved by Android string resources / iOS Localizable -- a cross-frontend refactor out of scope for the v2.0.1 Android re-port. Recorded as a tracked follow-up in `to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md` (§9, target v2.0.2-v2.0.4). Gates: `cargo test -p rustynes-mobile` (new + existing movie tests green), `cargo clippy -p rustynes-mobile --all-targets -- -D warnings` clean. The Kotlin DSL fix is validated by the android.yml best-effort bundle job on push. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iles
The `compileFossReleaseKotlin` step failed: `MainActivity.kt` (shared `src/main`)
built the experimental Chromecast (CAF) spectator button by directly referencing
`androidx.mediarouter.app.MediaRouteButton` and
`com.google.android.gms.cast.framework.CastButtonFactory`. Both types now live
only on the `play` classpath (the ADR 0025 split moved `play-services-cast-
framework` to `playImplementation`), and Kotlin type-checks the body of an
`if (BuildConfig.CHROMECAST_ENABLED) { ... }` block regardless of the constant's
value, so the `foss` (default / F-Droid / sideload) flavor could not resolve them
— an unresolved-reference hard failure on the default artifact.
Routed the button construction through the existing `ChromecastSender` flavor
façade, matching how Billing (`license.priceLabel`) and the CAF sender itself are
already split:
- `src/play/.../ChromecastSender.kt`: new `mediaRouteButton(Context): View` builds
the real `MediaRouteButton` wired via `CastButtonFactory.setUpMediaRouteButton`,
returned as a plain `android.view.View` (imports `androidx.mediarouter` +
`CastButtonFactory`, which resolve on the `play` classpath).
- `src/foss/.../ChromecastSender.kt`: no-op twin with the same signature returns a
blank inert `View(context)` — links no proprietary Cast type. It is never shown
(the sole caller is gated behind the default-off `CHROMECAST_ENABLED`); it exists
only to satisfy the shared `AndroidView` factory's non-null contract.
- `src/main/.../MainActivity.kt`: the `AndroidView` factory now calls
`chromecast.mediaRouteButton(ctx)`; `src/main` links no `androidx.mediarouter` /
`com.google.android.gms.cast.*`.
`grep` confirms `src/main` now carries zero proprietary Cast references (only
comments / string resources / façade type names remain). No behavior change: the
`play` button is byte-identical to before; `foss` never rendered it. The DSL half
(the `playImplementation` string-invoke) already compiles; this closes the flavor
split's last `src/main` leak so both `assembleFoss*`/`assemblePlay*` build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-report # Conflicts: # CHANGELOG.md
…acyCoin 141/141 + Android monetization glue) (#240) * docs: 2-cycle-ALE campaign plan + ADR 0030 update (first-principles needs the refactor) * feat(ppu): Phase 1 — 2-cycle-ALE fetch-split scaffold behind mc-ppu-2cycle-ale [behavior-neutral] Phase 1 of the v2.0.3->v2.0.4 2-cycle-ALE fetch refactor (ADR 0030 "Option 1", the Timebase-scale campaign the maintainer chose after the first-principles whole-dot refine was proven infeasible). Introduces the genuine two-dot VRAM-access STRUCTURE behind a new default-off cargo feature `mc-ppu-2cycle-ale` (declared in rustynes-ppu, forwarded through rustynes-core and rustynes-test-harness exactly like the v2.0.2 `mc-ppu-bus-addr-hybrid` stand-in). No corruptions yet — those are Phase 3. Model (ported from TriCNES `Emulator.cs` FetchPPU:150-177, commit 9199870): each background fetch is split across two PPU dots. The EVEN (ALE) dot drives the full 14-bit fetch address onto a new first-class `address_bus: u16` and captures its low byte into the `octal_latch` (the 74LS373); the ODD (read) dot resolves the effective address through the multiplexed-bus splice `(address_bus & 0x3F00) | octal_latch`, reads VRAM, and drives the DATA byte back onto the low 8 bits (AD7-0). For a coherent fetch the ALE-driven address equals what the read would compute (`v` is constant across the 8-dot group's phases 0..=6; the coarse-X increment is at phase 7 AFTER the read), so the splice returns the intended address and observable behavior is UNCHANGED — the split alone regresses nothing. Attribute + both pattern fetches are TRUE two-dot ALEs (ALE at phases 2/4/6, read at 3/5/7). The NAMETABLE fetch is a documented Phase-1 exception. Its MMC5 vertical-split query (`bg_split_state`) has a mapper-observable side effect (`split_chr_bank_latch`, which `nametable_fetch`/`chr_offset` read), and moving that call one dot earlier to a phase-0 ALE shifts the Uchuu Keibitai SDF split rendering (framebuffer-hash-only; cycle count and audio stay byte-identical). Empirically confirmed both single- and double-query at phase 0 diverge. So the NT split query stays at the read dot (phase 1) and the NT octal-latch load co-locates there via `ale_splice`'s not-armed path (no phase-0 NT ALE). Making NT a true two-dot fetch needs a non-mutating split PEEK at the ALE dot, deferred to Phase 3 alongside the corruptions. Implementation (all `#[cfg(feature = "mc-ppu-2cycle-ale")]`-gated; the shipped/default build compiles NONE of it and is byte-identical): - New PPU fields: `address_bus: u16`, `ale_armed: bool`; `octal_latch` is now shared with the hybrid model via `any(..)` (the two flags are alternative fetch models, never both-on in CI). None are snapshotted — they self-heal on the next fetch ALE, so PPU_SNAPSHOT_VERSION stays at 4 (no format bump). - New ALE dispatch in `tick` (phases 2/4/6) + helpers `ale_drive_at`, `ale_drive_bg_lo`, `ale_drive_bg_hi`, `drive_bus`, `ale_splice`, `ale_drive_data`. `ale_armed` distinguishes a read that followed a real ALE (main dispatch) from one with none (the dot-337-340 garbage NT fetches), so the latter stays transparent (drive+latch in place) and never splices a stale bus. - A12/mapper notification (`observe_a12_addr`) stays on the INTENDED (un-spliced) address at every call site, so fetch-address/A12 timing is unchanged — the top silent-regression risk the plan flagged. STOP-OR-GO gate — flag-OFF byte-identical AND flag-ON full battery green, both verified (release, sccache-safe flock): - flag-off baseline: AccuracyCoin 139/141 (98.58%), nestest 0-diff, mmc3 18/18 (A12 IRQ clocking), ppu_sprites 19/19 (sprite-zero), mmc1_a12, nes_blargg 8/8, 60-ROM commercial oracle 60/60. - flag-on (`mc-ppu-2cycle-ale`): IDENTICAL — AccuracyCoin 139/141 (same two expected PPU failures; corruptions are Phase 3), nestest 0-diff, mmc3 18/18, ppu_sprites 19/19, mmc1_a12, nes_blargg 8/8, and the 60-ROM commercial byte-identity oracle 60/60 (byte-identical to the flag-off baseline, including the MMC5 titles Castlevania 3 / Uchuu Keibitai SDF / Bandit Kings). - `cargo fmt --all --check` clean; `cargo clippy -D warnings` clean for the workspace default AND the `mc-ppu-2cycle-ale` flag-on config. Phase 1 of a multi-phase campaign: no corruptions, no promotion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ppu): Phases 2+3 — 2-cycle-ALE corruptions arise NATURALLY, AccuracyCoin 141/141 flag-on Phases 2 (NT true two-dot fetch) + 3 (both corruptions, naturally) of the v2.0.3->v2.0.4 2-cycle-ALE fetch refactor (ADR 0030 "Option 1"), behind the default-off `mc-ppu-2cycle-ale` feature. Both new AccuracyCoin PPU tests now pass flag-on with NO reconstruction: the octal latch carries the stale bytes on its own, per the TriCNES transistor-level model. The v2.0.2 `+1 coarse-X` whole-dot stand-in is not used here. Result (release, sccache-safe flock): - flag-ON (`mc-ppu-2cycle-ale`): AccuracyCoin 141/141 (100.00%) — both "ALE + Read" ($0491) AND "Hybrid Addresses" ($0492) = $01. - flag-OFF (shipped/default): byte-identical — AccuracyCoin 139/141, nestest 0-diff (none of this code compiles into the default build). Model (ported from TriCNES `Emulator.cs`, MIT, commit 9199870): Phase 2 — NT true two-dot fetch. A new phase-0 nametable ALE (`ale_drive_nt`) drives the PLAIN `0x2000 | (v & 0x0FFF)` address and loads `octal_latch` with its low byte one dot BEFORE the phase-1 read; the read (`fetch_nt`) resolves through the armed `ale_splice` = `(address_bus & 0x3F00) | octal_latch` (TriCNES `PPU_Render_ShiftRegistersAndBitPlanes` case 0/1). The MMC5 vertical-split query keeps living at the read dot (its `split_chr_bank_latch` side effect is mapper-observable); when it returns split-active, `fetch_nt` disarms the phase-0 ALE and reads the synthesized `split.nt_addr` co-located — so split rendering is byte-identical WITHOUT needing a new non-mutating peek trait method (deferring the correction to the read dot achieves the same end). This resolves the Phase-1 "NT not yet a true 2-dot ALE" finding. Phase 3a — "Hybrid Addresses" via the delayed-`CopyV` countdown (`copy_v_delay`, TriCNES `PPU_Update2006Delay` :1684-1704). A `$2006` second write in the active BG-fetch window no longer copies `t -> v` immediately; it stages a `COPY_V_DELAY = 4`-dot countdown (RustyNES's fixed CPU/PPU alignment == TriCNES's delay-4 case; the ROM's `.word` retries cover its two answer-key alignments and the fixed one hits). While the countdown runs the fetch cadence advances coarse-X (`inc_hori_v` at phase 7) and the per-group phase-0 NT ALE keeps loading the CURRENT (pre-copy) `v`'s NT-low — so by the landing `octal_latch` NATURALLY holds the one-tile-ahead `$19`; the landing (top of `tick`, before the fetch dispatch) sets `address_bus = v` (new `t`, high `$2F00`) and the next NT read splices `$2F00 | $19 = $2F19`. No `(v&0xE0)| (coarse_x+1)` reconstruction — the +1 coarse-X stand-in is deleted from this path. The deferral is gated to the active-fetch window (dots 1..=256 + the 321..=336 prefetch), the only region a BG fetch can consume the stale latch, so HBlank-window scroll writes are untouched. Verified natural by the octal trace: `W2006 @ sl4 dot182 -> HYBRID $2F19 @ dot186` (delay exactly 4). Phase 3b — "ALE + Read" reuses the existing `render_data_bus` / `ppudata_sm_countdown` machinery: at the PPUDATA state-machine landing during render the latch freezes on the read's DATA byte (`pattern_latch_stale`), and `drive_bus` suppresses the next pattern ALE's latch reload so the pattern read splices `(PAR high 6):(stale $FF) = $0FFF` — the natural multiplexed-bus splice, no explicit reconstruction. Verified: `SMLAND $FF @ sl3 dot228 -> STALE $0FFF @ dot230`. A12/mapper notify stays on the INTENDED (un-spliced) address at every call site; the only A12-timing change is the `$2006`-write edge, delayed 4 dots during render (rare, verified inert on the MMC3/MMC1 A12 suites). No snapshot format bump — `address_bus`/`ale_armed`/`octal_latch`/`copy_v_delay`/ `pattern_latch_stale` all self-heal within a scanline; `PPU_SNAPSHOT_VERSION` stays at 4. Implementation (all `#[cfg(feature = "mc-ppu-2cycle-ale")]`-gated): - new field `copy_v_delay: u8`; `pattern_latch_stale` widened to `any(hybrid, 2cycle)`; `octal_trace` module + re-export widened to `any(...)` so the 2cycle build can cross-diff against TriCNES via `RUSTYNES_OCTAL_TRACE=1`. - new `ale_drive_nt`; `drive_bus` gains an `is_pattern` arg + the stale-freeze suppression; `ale_splice` gains a diagnostic HYBRID/STALE trace push; `fetch_nt` armed-splice / split-disarm; the `$2006` case-6 delayed copy; the countdown landing at the top of `tick`; the SM-landing freeze widened. Regression battery (flag-ON): nestest 0-diff; mmc3 A12-IRQ 18/18; ppu_sprites (sprite-zero) all pass; mmc1_a12 pass; nes_blargg all pass. `cargo fmt` + `clippy -D warnings` clean for the default, `mc-ppu-bus-addr-hybrid`, and `mc-ppu-2cycle-ale` configs; `no_std` cross-compile clean. 60-ROM commercial byte-identity oracle (flag-ON): 58/60 byte-identical. The two changes are Super Mario Bros. 3 (8 px, scanline 194 cols 0-7) and Uchuu Keibitai SDF (7 px, scanline 15 cols 152-158) — both legitimately do `$2006`-during-active-render, both differ ONLY in the framebuffer (audio FNV + CPU-cycle count byte-identical), each a single-tile sub-scanline shift at the exact scanline of a mid-render `$2006` scroll write: the precise 2-cycle-ALE artifact, MORE TriCNES-faithful than the flag-off immediate-`v=t` approximation. The committed `insta` snapshots are LEFT at the flag-off values so the shipped build stays byte-identical; the two flag-on diffs are documented exceptions to re-bless (or make feature-conditional) at the Phase-4 promotion gate. Full analysis in ADR 0030's final update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ppu)!: promote 2-cycle-ALE PPU model to default — shipped AccuracyCoin 141/141, retire experimental flags (ADR 0030) Promote the first-principles 2-cycle-ALE PPU fetch model from the experimental `mc-ppu-2cycle-ale` flag to the shipped default and the ONLY PPU fetch path, following the v2.0.0 precedent that promoted the one-clock scheduler by deleting its flag. The full machinery is now unconditional: the phase-0/2/4/6 ALE-drive dots (`ale_drive_nt/at/bg_lo/bg_hi` → `drive_bus`), the first-class 74LS373 `octal_latch`, the multiplexed-bus read-half splice `(address_bus & 0x3F00) | octal_latch` (`ale_splice`), the delayed-`CopyV` "Hybrid Addresses" countdown (`copy_v_delay`, TriCNES `PPU_Update2006Delay`), and the `$2007`-ALE-overlap "ALE + Read" freeze (`pattern_latch_stale`). This is a shipped-behavior change (hence `!`): two commercial titles now render more TriCNES-faithfully at a mid-render `$2006` scroll write — Super Mario Bros. 3 (one leftmost tile, scanline 194) and Uchuu Keibitai SDF (one tile at the MMC5 split boundary, scanline 15). Their `insta` snapshots are re-blessed; each changes ONLY its `checkpoint f600 fb_fnv1a64` line (cycle count + audio hash byte-identical — a pure PPU-internal timing shift). No other title moved. Retire BOTH experimental flags. `mc-ppu-2cycle-ale` (the now-default model) and `mc-ppu-bus-addr-hybrid` (the superseded v2.0.2 whole-dot `+1 coarse-X` stand-in) are deleted from rustynes-ppu / rustynes-core / rustynes-test-harness Cargo.toml and all forwards. The v2.0.2 stand-in code is removed: the `octal_effective` splice function, the `OctalFetch` enum, the `copy_v` one-shot, and the `(v & 0xE0)|(coarse_x+1)` coarse-X reconstruction. Zero references to either flag remain in crates/. The `octal_trace` calibration ring is kept behind a new default-off dev feature `ppu-octal-trace` (it does NOT gate emulation behavior): with the feature off, `push` is a zero-cost no-op stub and the ring's 64-bit-atomic storage is absent, so the shipped hot path is untouched and the `#![no_std]` chip stack — whose thumbv7em target lacks 64-bit atomics — still cross-compiles. Save-state: add a `PPU_SNAPSHOT_VERSION` v5 tail serializing the five in-flight fetch fields (`octal_latch`, `address_bus`, `ale_armed`, `pattern_latch_stale`, `copy_v_delay`). This corrects the pre-promotion assumption that no persisted PPU fields were needed ("stays at 4"): making the model the default path made it the substrate for netplay rollback, whose save/restore round-trip revealed that a mid-render checkpoint can land with these fields live — desyncing the re-simulated framebuffer. Three rustynes-netplay rollback-determinism tests regressed on promotion and are fixed by the v5 tail (all 16 now pass). The change is ADDITIVE and NOT a load-break: pre-v5 .rns blobs upconvert with the fields at their inactive rest defaults (0/false), the state a save taken at a fetch boundary always holds — the same pattern the v3 ($2007 state machine) and v4 (overclock countdown) tails used. Performance: the two-dot fetch transaction costs ~10% over the R1 baseline (nestest headless ~4.15 ms/frame, ~4x realtime, vs ~3.77 ms), well inside the 16.639 ms NTSC wall. Accepted for the accuracy gain. Shipped-default verification (no feature flags): AccuracyCoin 141/141 (100.00%, RAM-authoritative, zero failing tests — $0491 and $0492 both pass); nestest 0-diff; mmc3 A12-IRQ 18/18 (+5 pre-existing by-design ignores); ppu_sprites 19/19; mmc1_a12; nes_blargg 8/8; 60-ROM commercial oracle 60/60 (2 re-blessed titles only); full `cargo test --workspace` green; fmt / clippy -D warnings / rustdoc -D warnings / thumbv7em no_std all clean. Docs: ADR 0030 gains a "Promoted (v2.0.3)" section (including the honest correction of the "no snapshot bump" note); docs/performance.md records the ~4.15 ms baseline; CHANGELOG [Unreleased] documents the promotion, flag retirement, and v5 tail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(android): v2.0.3 "Harbor" foss/play monetization glue + host-localizable bridge warnings Wire the freemium / ad-supported monetization glue into the Android build behind the foss/play flavor split (ADR 0025), and make the rustynes-mobile host warnings localizable. Every change is additive and off-by-default: the FOSS (F-Droid / GitHub-Releases) artifact links no ads / store SDK and stays behaviourally identical to the pre-monetization build, and the desktop/wasm frontends are untouched. Monetization facade (ADR 0025 step 5) ------------------------------------- Follows the repo's established twin-facade idiom (LicenseManager/Cast/Integrity): a single MonetizationGate class exists in both source sets — a byte-compatible no-op in src/foss (every feature free, no ads, no gate, never even constructs the AdPolicy core or loads its JNA library) and the real implementation in src/play. MainActivity (a src/main file) touches only MonetizationGate, so it links against either flavor unchanged. This side-steps the #235 flavor gotcha: Kotlin type-checks inside `if (BuildConfig.PLAY_BUILD)` regardless of the flag, so any AppLovin/RevenueCat symbol referenced from shared code would break the default foss compile — confining all com.applovin.* / com.revenuecat.* references to src/play (AdGate, RewardedGate, RcBilling) and the play MonetizationGate twin is what keeps foss clean. The play twin owns the shared Rust AdPolicy core (all policy — ad cadence, feature gating, the free-tier play budget, offline grace — lives in Rust so Android and iOS cannot diverge), initializes AppLovin MAX + RevenueCat off the cold-start path (onActivityCreated, mirroring the deferred Billing connect), routes featureEnabled(PremiumFeature) / begin_session / start_play / export_progress / restore_progress through the core, and draws the run-out paywall + live mm:ss countdown (RunOutOverlay, a Compose overlay layered over EmulatorScreen). The FOSS twin's RunOutOverlay draws nothing, so the shared Box in MainActivity is visually and behaviourally inert in the clean build. AdGate/RewardedGate/RcBilling are adapted from crates/rustynes-monetization/shells/android; RcBilling was corrected to the RevenueCat 8.10.0 API (UpdatedCustomerInfoListener moved to .interfaces, restorePurchasesWith KTX helper, nullable StoreTransaction? in the purchase callback) since the shell was written against an older API shape. Gradle / manifest / R8 (steps 1-2, 4, 6) ---------------------------------------- - productFlavors.play gains APPLOVIN_SDK_KEY / REVENUECAT_API_KEY / MAX_INTERSTITIAL_AD_UNIT_ID / MAX_REWARDED_AD_UNIT_ID buildConfig fields (from gradle properties, empty default so a keyless dormant build links), plus TESTER_UNLOCK per buildType (debug=true/release=false) so the RevenueCat local-QA unlock is compiled out of every store build. - AppLovin MAX 13 + RevenueCat 8 added as playImplementation ONLY; AppLovin's Maven repo added in settings.gradle.kts scoped to the com.applovin group. The foss variant links neither. - src/play/AndroidManifest.xml carries the AD_ID permission + AdMob APPLICATION_ID (the official Google test id placeholder) so these never merge into the foss manifest. - Paywall strings (en + es) live in src/play/res so they merge into the play variant only, keeping the foss resource table clean. - proguard-rules.pro: strict-full-mode keeps for the second UniFFI package (com.doublegate.rustynes.monetization.ffi.**), the AppLovin SDK + mediation adapters, and the RevenueCat surface — documented per rule; no-op keeps in the foss release. - app versionCode 20001 -> 20003, versionName "2.0.1" -> "2.0.3" (the v2.0.2 cut was desktop-only, so the Android host advances directly to 2.0.3). Baseline Profile / vitals (steps 3-4) ------------------------------------- The :baselineprofile module (StartupBenchmark + BaselineProfileGenerator, already flavor- pinned to foss) is refreshed with MAINTAINER (v2.0.9) notes that the profile must be regenerated on-device against the v2.0.0 "Timebase" core (the one-clock rewrite reshaped the launch/UniFFI hot-class set); only config/plumbing is touched here — the on-device capture, Play Console vitals dashboard, foldable WYSIWYG, and live monetization runtime verification are device-gated and deferred to v2.0.9. Host-localizable bridge warnings (PR #235 follow-up, step 7) ------------------------------------------------------------ rustynes-mobile: introduce an additive, non-breaking HostWarning enum (UniFFI-exported) so hosts can localize the drainable warnings that were previously pre-baked English Strings. HostWarning::PreTimebaseMovie::message() returns the exact prior English verbatim; a new drain_warning_codes() -> Vec<HostWarning> exposes the codes, while drain_warnings() -> Vec<String> is kept working (it now maps codes through message()), so existing Kotlin/Swift hosts are unbroken and the default output is byte-identical. Added a test asserting the code maps to the exact legacy string; the two existing movie-warning tests stay green. The chip stack is untouched (no accuracy impact); the desktop/wasm frontends carry their own identical warning and are deliberately left unchanged (parity unchanged). Verification: cargo test/clippy/fmt -p rustynes-mobile green (27 tests); ./gradlew :app:assembleFossDebug and :app:assemblePlayDebug both BUILD SUCCESSFUL (both flavors link + assemble). On-device runtime verification is out of scope (no device). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * release: cut v2.0.3 "Harbor" (2-cycle-ALE promoted to default — AccuracyCoin 141/141 + Android monetization glue) The third release of the v2.0.x "Harbor" mobile-finalization train, and the one that makes the octal-latch accuracy work real at the shipped default. It bundles the two workstreams developed in parallel on this cut: 1. PPU: promote the 2-cycle-ALE fetch model to the unconditional default (ADR 0030). The genuine two-dot VRAM transaction (even-dot ALE-drive + octal_latch load; odd-dot `(address & 0x3F00) | octal_latch` splice + read) becomes the only PPU fetch path, retiring BOTH experiment flags (`mc-ppu-2cycle-ale` and the superseded v2.0.2 whole-dot `mc-ppu-bus-addr-hybrid` stand-in) and deleting the v2.0.2 stand-in code (octal_effective, the OctalFetch enum, copy_v, the +1 coarse-X reconstruction). The latch now NATURALLY carries the stale byte: the delayed CopyV (copy_v_delay = 4, RustyNES's fixed alignment) makes the nametable read splice $2F19 for "Hybrid Addresses" ($0492), and the $2007-ALE overlap freezes the latch on $FF -> $0FFF for "ALE + Read" ($0491). Shipped AccuracyCoin is now 141/141 (100.00%, RAM-authoritative) -- both new upstream PPU tests pass out of the box (previously an honest 139/141). Option 1 is fully landed. Save-state: promotion made the model the netplay-rollback substrate, whose save/restore round-trip exposed a determinism gap in the five in-flight fetch fields (octal_latch, address_bus, ale_armed, pattern_latch_stale, copy_v_delay). They are now serialized in an ADDITIVE PPU_SNAPSHOT_VERSION 4 -> 5 tail (pre-v5 .rns still load, upconverting to inactive rest defaults -- the same pattern the v3 $2007-state-machine and v4 overclock-countdown tails used). A v2.0.3 save-state is forward-incompatible with <= v2.0.2 but this is an in-train sub-version bump, NOT an ADR-0028 .rns format-epoch break. 2. Android + mobile bridge (Harbor v2.0.3 polish + foss/play step 5). - foss/play monetization glue: the play flavor gains a real MonetizationGate (AppLovin MAX ad glue via AdGate/RewardedGate + a RevenueCat 8.10.0 entitlement wrapper RcBilling, driving the Rust AdPolicy core from rustynes-monetization), feature_enabled gating, the run-out paywall + mm:ss countdown Compose overlay, begin_session at launch, export/restore_progress persistence, and the offline-grace path. The foss flavor keeps a no-op MonetizationGate twin (every feature free, no ads, never touches the FFI ad core), so the default F-Droid artifact stays behaviour-identical. Ad SDKs + AD_ID/AdMob manifest + paywall strings (en/es) live in src/play/ only; R8 full-mode keeps cover the monetization UniFFI package + AppLovin + RevenueCat. Both flavors assemble; monetization is behaviourally dormant pending on-device runtime verify (v2.0.9). Android versionCode 20001 -> 20003, versionName -> "2.0.3". - Host-localizable mobile bridge warnings: rustynes-mobile gains a UniFFI-exported HostWarning enum (first variant PreTimebaseMovie, the ADR 0028 .rnm warning) + an additive drain_warning_codes() + an exported host_warning_message(). The existing drain_warnings() -> Vec<String> is preserved verbatim (maps each code to its English message()), so current Kotlin/Swift hosts are unbroken; desktop/wasm parity is unchanged. Verification (all green, independently re-verified on the merged tree): - AccuracyCoin 141/141 (100.00%, RAM-authoritative) on the default build. - 60-ROM commercial byte-identity oracle 60/60, with exactly two documented re-blesses -- Super Mario Bros. 3 (one leftmost tile on scanline 194) and Uchuu Keibitai SDF (one tile at the MMC5 split boundary on scanline 15) now render more TriCNES-faithfully at a mid-render $2006 scroll write; audio + cycle counts stay byte-identical, no other title changed. - nestest 0-diff; mmc3 A12-IRQ 18/18; ppu_sprites 19/19; all 16 netplay rollback-determinism tests pass (the v5 fix). - cargo fmt/clippy (workspace)/RUSTDOCFLAGS doc all clean; Rust rustynes-mobile 27 tests pass; both Android flavors assemble (foss + play). - Perf: nestest ~4.15 ms/frame (~4x realtime, ~10% over the R1 baseline), recorded in docs/performance.md; accepted for the accuracy gain. Sequencing note: the campaign plan nominally slotted the flag flip at v2.0.4; per the maintainer's firmed mapping (Option 1 fully complete within v2.0.2->v2.0.4) it landed at v2.0.3, leaving v2.0.4 for the Android RC + foss/play finalization-window closeout. Docs reconciled (STATUS.md single source of truth, ROADMAP, the plan doc). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ppu): re-bless scanline.nes visual-regression golden for the 2-cycle-ALE promotion The v2.0.3 promotion of the 2-cycle-ALE PPU fetch model to the default changes the framebuffer of `nes-test-roms/scanline/scanline.nes` — the one public test ROM in the `visual_regression` suite that drives mid-scanline scroll/timing via mid-render $2006 writes, exactly the mechanism the octal-latch / delayed-CopyV model makes more accurate. The `scanline_frame_180` FNV-1a golden shifts eb8260a4cf3a1f25 -> 39e8052eedc7f4d5. This is the expected, surgical consequence of the accuracy improvement, verified: - Deterministic: the locally reproduced hash is byte-identical to CI's, so this is genuine 2-cycle-ALE behavior, not platform noise (the determinism contract holds). - Localized: only this one golden shifted. The other eight visual goldens — including the NMI-drift-sensitive `nmi_sync/demo_ntsc.nes` demo that "glitches visibly if NMI timing drifts" — stayed byte-identical, confirming no collateral timing drift. A full --no-fail-fast sweep of every public-ROM snapshot binary (visual_regression, extra_scanlines, ppu_read_buffer, p240_test_suite, m22, mmc1_a12, dmc_dma, audio_expansion, volume_tests, f2_accuracy_audit, blargg_nes_cpu_test5, vs_dualsystem, read_joy3, input_devices, movie) surfaced no other shift. - Model-correct: the 2-cycle-ALE fetch is per-dot verified against the TriCNES oracle; AccuracyCoin is 141/141 and the 60-ROM commercial oracle 60/60. The promotion commit re-blessed the two commercial oracle snapshots (SMB3, Uchuu Keibitai SDF) but missed this one because the harness ran `cargo test --workspace` (no test-roms feature) plus targeted suites, so the test-roms-gated visual_regression golden was not exercised in full. CHANGELOG [2.0.3] updated to record the re-bless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(v2.0.3): address gemini + copilot PR #240 review — monetization lifecycle, stale docs, README Resolves all 15 bot review threads on PR #240 (gemini-code-assist x8, copilot x7). Android monetization glue (play flavor) — real fixes, both flavors still compile: - AdGate / RewardedGate: add destroy() that detaches the MaxAd listener, so the long-lived interstitial/rewarded ad instances no longer retain a destroyed Activity (leak on rotation). MonetizationGate.onDestroy() now calls both + clears the refs. - Rewarded-ad resume bridge: the RewardedGate reward callback was an empty lambda, so a free user who watched a rewarded ad would never have the emulator unpaused (show() is non-blocking, so the resume can't be returned inline). Added an onResumeCallback that RunOutOverlay sets and the gate invokes on grant. - RunOutOverlay LaunchedEffect keyed on `premium` (was Unit) + short-circuits for premium users, eliminating the always-on 1 Hz wakeup / battery drain when nothing is gated. - formatMmSs forces Locale.US so the countdown uses ASCII digits (a non-ASCII-numeral device locale could render digits the label font lacks). - Corrected the "SDK init deferred off cold-start" comments (MainActivity + the class doc): onActivityCreated is invoked from onCreate, so init is on the launch path (guarded to run once); posting it off the first-frame path is flagged as a v2.0.9 refinement. - build.gradle.kts: versionCode/versionName rationale is now a self-contained block comment instead of a parenthetical split across the two assignments. PPU (stale save-state comments now that v5 serializes the fields): - The in-flight-fetch-fields block comment + the octal_latch / address_bus doc comments said "Not snapshotted / PPU_SNAPSHOT_VERSION unchanged". They ARE serialized in the v5 tail (v2.0.3) for netplay-rollback determinism; comments corrected to say so (still additive, still not an ADR-0028 epoch break; they self-heal only in continuous play). README (missed spots from the 141/141 promotion): - The dedicated AccuracyCoin badge SVG still read 98.58% (139/141) -> 100% (141/141). - The overview parenthetical still said "two remaining failures ... known gaps" -> now states every assigned test passes as of v2.0.3 via the promoted 2-cycle-ALE model. Verified: cargo fmt/clippy/rustdoc on rustynes-ppu clean; ./gradlew compilePlayDebugKotlin + compileFossDebugKotlin BUILD SUCCESSFUL (only the pre-existing AppLovin showAd() deprecation warnings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR C — v2.0.1 Android core re-port onto Timebase +
foss/playflavor-split startFirst release of the Android mobile-finalization re-port train (v2.0.1 → v2.0.4) onto the
v2.0.0 "Timebase" core, plus the structural start of the
foss/playproduct-flavorsplit (ADR 0025). Host/Android-side only — the deterministic chip stack is byte-identical,
so host CI accuracy is unchanged by construction (AccuracyCoin 139/141).
What changed
1. Version bump —
android/app:versionCode 20000 → 20001,versionName "2.0.0" → "2.0.1".2. Movie-epoch warning on the mobile bridge (ADR 0028) —
rustynes-mobile'sNesController::movie_playpeeksrustynes_core::recorded_before_v2_timebase(&bytes)and, for apre-v2.0.0
.rnm, queues a drainable host warning via the newNesController::drain_warnings(),mirroring the desktop + wasm frontends. Input replay still proceeds; the host can now warn that
exact framebuffer/audio reproduction is not guaranteed across the engine-timebase boundary. A
pre-v2.0.0
.rnsrestore already maps to a cleanMobileError::SaveState(no crash). Newwarnings: Vec<String>onInner(init innew, cleared onload_rom); the deterministic corenever reads it. The fm2 replay test now asserts a current-epoch
.rnmraises no warning.3.
foss/playflavor split — STRUCTURAL (ADR 0025); ad glue stays dormant:distributionflavor dimension:foss(isDefault = true) +play,PLAY_BUILDper-flavor.update/review) moved to
playImplementation(…)— thefossvariant links none of them.src/play/, each with a byte-compatibleno-op twin in
src/foss/(identical public surface, nocom.google.*/com.android.billingclient.*). Shared pure types (PgsIds,IntegrityVerdict,DEMO_SESSION_SECONDS) moved tosrc/main/PlayFacadeShared.kt.MainActivitynow reads aflavor-neutral
LicenseManager.priceLabel(aString) instead of the GoogleProductDetailstype — the only Google-typed leak into the shared shell. The AOSP Presentation-API
CastManageris not proprietary and stays in
src/main.OPTIONS_PROVIDER+ Play-GamesAPP_IDmeta-data moved tosrc/play/AndroidManifest.xmlso they never merge into thefossmanifest.installDebug→installFossDebugalias (guarded against the AGP anchor).:baselineprofile(
com.android.testtargeting the now-flavored:app) gainsmissingDimensionStrategy("distribution", "foss").android.ymlbundles both flavors.4. Docs — created the previously-dangling
to-dos/v1.8.x-on-device-verification.md(themanual on-device checklist the plan cites); updated
docs/android.md,docs/STATUS.md, and theCHANGELOG[Unreleased]section.Verification
Green locally:
cargo fmt --all --check,cargo clippy -p rustynes-mobile --all-targets -D warnings,cargo check -p rustynes-mobile -p rustynes-monetization,cargo check --workspace,cargo test -p rustynes-mobile(the movie /drain_warningstest), andmarkdownlinton everytouched
.md.Deferred to
android.ymlCI (the real gate): the NDK cross-build, UniFFI binding regen, andthe both-flavor Gradle packaging — no local NDK/emulator, per the plan. The Gradle Kotlin-DSL and
the
main/foss/playsource-set + manifest structure were validated by careful review.Notes / caveats
the ad wiring, on-device dual-flavor verification, and the F-Droid submission are the v2.1.0 step.
!marks the save-state/movie epoch boundary this train rides (ADR 0028).🤖 Generated with Claude Code