Skip to content

feat(android)!: v2.0.1 core re-port onto Timebase + foss/play flavor-split scaffolding - #235

Merged
doublegate merged 4 commits into
mainfrom
feat/v2.0.1-c-android-report
Jul 8, 2026
Merged

doublegate merged 4 commits into
mainfrom
feat/v2.0.1-c-android-report

Conversation

@doublegate

Copy link
Copy Markdown
Owner

PR C — v2.0.1 Android core re-port onto Timebase + foss/play flavor-split start

First 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/play product-flavor
split (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's
NesController::movie_play peeks rustynes_core::recorded_before_v2_timebase(&bytes) and, for a
pre-v2.0.0 .rnm, queues a drainable host warning via the new NesController::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 .rns restore already maps to a clean MobileError::SaveState (no crash). New
warnings: Vec<String> on Inner (init in new, cleared on load_rom); the deterministic core
never reads it. The fm2 replay test now asserts a current-epoch .rnm raises no warning.

3. foss/play flavor split — STRUCTURAL (ADR 0025); ad glue stays dormant:

  • distribution flavor dimension: foss (isDefault = true) + play, PLAY_BUILD per-flavor.
  • All six proprietary SDK groups (Billing, Cast framework, Play Games v2, Play Integrity, in-app
    update/review) moved to playImplementation(…) — the foss variant links none of them.
  • Façade: the six Google-touching glue classes moved to src/play/, each with a byte-compatible
    no-op twin in src/foss/ (identical public surface, no com.google.* /
    com.android.billingclient.*). Shared pure types (PgsIds, IntegrityVerdict,
    DEMO_SESSION_SECONDS) moved to src/main/PlayFacadeShared.kt. MainActivity now reads a
    flavor-neutral LicenseManager.priceLabel (a String) instead of the Google ProductDetails
    type — the only Google-typed leak into the shared shell. The AOSP Presentation-API CastManager
    is not proprietary and stays in src/main.
  • Manifest: Cast OPTIONS_PROVIDER + Play-Games APP_ID meta-data moved to
    src/play/AndroidManifest.xml so they never merge into the foss manifest.
  • installDebug → installFossDebug alias (guarded against the AGP anchor). :baselineprofile
    (com.android.test targeting the now-flavored :app) gains
    missingDimensionStrategy("distribution", "foss"). android.yml bundles both flavors.

4. Docs — created the previously-dangling to-dos/v1.8.x-on-device-verification.md (the
manual on-device checklist the plan cites); updated docs/android.md, docs/STATUS.md, and the
CHANGELOG [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_warnings test), and markdownlint on every
touched .md.

Deferred to android.yml CI (the real gate): the NDK cross-build, UniFFI binding regen, and
the both-flavor Gradle packaging — no local NDK/emulator, per the plan. The Gradle Kotlin-DSL and
the main/foss/play source-set + manifest structure were validated by careful review.

Notes / caveats

  • The split is structural only — monetization (AppLovin MAX + RevenueCat) remains dormant;
    the ad wiring, on-device dual-flavor verification, and the F-Droid submission are the v2.1.0 step.
  • The ! marks the save-state/movie epoch boundary this train rides (ADR 0028).

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings July 8, 2026 06:40

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt Outdated
Comment thread crates/rustynes-mobile/src/lib.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.1 and introduce a distribution flavor dimension (foss default, play), scoping proprietary Google SDK deps + glue into play.
  • Mobile bridge: add a drainable warnings queue (NesController::drain_warnings) and queue a warning when playing pre-v2.0.0 .rnm movies.
  • CI/docs: update android.yml to 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.

Comment thread android/app/src/foss/java/com/doublegate/rustynes/CloudSave.kt
Comment thread crates/rustynes-mobile/src/lib.rs Outdated
Comment thread crates/rustynes-mobile/src/lib.rs
doublegate and others added 3 commits July 8, 2026 03:22
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>
@doublegate
doublegate merged commit 1e4f036 into main Jul 8, 2026
23 checks passed
@doublegate
doublegate deleted the feat/v2.0.1-c-android-report branch July 8, 2026 08:45
doublegate added a commit that referenced this pull request Jul 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants