Skip to content

Commit fd5ad88

Browse files
doublegateclaude
andauthored
release: cut v2.0.3 "Harbor" (2-cycle-ALE promoted to default — AccuracyCoin 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>
1 parent 5b4effb commit fd5ad88

34 files changed

Lines changed: 2142 additions & 257 deletions

File tree

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

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# RustyNES v2.0.3 — "Harbor"
2+
3+
**The third release of the v2.0.x "Harbor" train — and the one that makes the
4+
octal-latch accuracy work real at the shipped default.** The 2-cycle-ALE PPU
5+
fetch model that v2.0.2 verified behind an experimental flag is **promoted to the
6+
unconditional default and only fetch path** (ADR 0030), so the shipped build now
7+
scores **AccuracyCoin 141/141 (100.00%)** — both new upstream PPU tests, **"ALE +
8+
Read"** (`$0491`) and **"Hybrid Addresses"** (`$0492`), pass out of the box
9+
(previously an honest 139/141). Option 1 — the first-principles 2-cycle-ALE fetch
10+
refactor — is fully landed.
11+
12+
Alongside the accuracy headline, v2.0.3 lands the **Harbor Android polish +
13+
foss/play monetization glue** and a **host-localizable mobile bridge-warning**
14+
API. No store submission yet (that is v2.1.0); Android continues as
15+
GitHub-sideload.
16+
17+
---
18+
19+
## The 2-cycle-ALE fetch model, promoted (ADR 0030)
20+
21+
The real 2C02 PPU multiplexes its low eight VRAM address pins with the eight VRAM
22+
data pins (PA0-7 = AD7-0); every VRAM access is a two-cycle transaction, and an
23+
external octal latch (74LS373-class) captures A7-A0 on the ALE-high cycle and
24+
supplies them back on the ALE-low cycle. When the address halves desync, the PPU
25+
reads from a **hybrid address it never coherently output** — the mechanism the
26+
two new tests exercise.
27+
28+
v2.0.2 shipped a whole-dot approximation of this behind the default-off
29+
`mc-ppu-bus-addr-hybrid` flag (a `+1 coarse-X` latch-capture stand-in, exact only
30+
for the tested alignment). v2.0.3 replaces it with a **genuine two-dot fetch**
31+
that models each VRAM access as a real ALE-drive + read pair, so the octal latch
32+
*naturally* carries the stale byte across dots — no reconstruction:
33+
34+
- **Even dot (ALE):** drive the full fetch address; load `octal_latch =
35+
(address & 0xFF)`. A12 / mapper notify fires here, on the real bus event.
36+
- **Odd dot (read):** effective address `(address & 0x3F00) | octal_latch`; read
37+
VRAM/CHR; write the DATA byte back onto the low bus.
38+
- **Hybrid Addresses** (`$0492`): a mid-fetch `$2006` second write during render
39+
stages a delayed `CopyV` (`copy_v_delay = 4`, RustyNES's fixed alignment); the
40+
next nametable fetch splices `{new v high $2F00}:{one-tile-ahead stale $19}` =
41+
`$2F19` naturally.
42+
- **ALE + Read** (`$0491`): a mid-render `$2007` read freezes `octal_latch` on the
43+
read's stale DATA byte; the next pattern fetch reads `{PAR high 6}:{$FF}` =
44+
`$0FFF`.
45+
46+
Both flags — `mc-ppu-2cycle-ale` (the now-default model) and the superseded
47+
`mc-ppu-bus-addr-hybrid` (v2.0.2's whole-dot stand-in) — are **retired**, and the
48+
v2.0.2 stand-in code (`octal_effective`, the `OctalFetch` enum, `copy_v`, the
49+
coarse-X reconstruction) is deleted. This follows the v2.0.0 precedent of
50+
promoting the one-clock scheduler by deleting its flag.
51+
52+
## Verification — the honesty bar
53+
54+
- **AccuracyCoin 141/141 (100.00%, RAM-authoritative)** on the default build —
55+
both `$0491` and `$0492` pass; independently re-verified.
56+
- nestest 0-diff; mmc3 A12-IRQ **18/18**; `ppu_sprites` **19/19**; `mmc1_a12`;
57+
blargg/kevtris all green.
58+
- **60-ROM commercial byte-identity oracle: 60/60**, with exactly two documented
59+
re-blesses — **Super Mario Bros. 3** (one leftmost tile on scanline 194) and
60+
**Uchuu Keibitai SDF** (one tile at the MMC5 split boundary on scanline 15) now
61+
render *more* TriCNES-faithfully at a mid-render `$2006` scroll write; audio and
62+
cycle counts stay byte-identical, and no other title's output changed.
63+
- `cargo test --workspace` green; fmt / clippy (all feature combos) /
64+
`RUSTDOCFLAGS=-D warnings cargo doc` / `thumbv7em-none-eabihf` no_std
65+
cross-compile all clean.
66+
- Headless frame cost rises ~10% (nestest ~4.15 ms/frame, still ~4x realtime),
67+
recorded in `docs/performance.md`; accepted for the accuracy gain.
68+
69+
## Save-state note (upgrade)
70+
71+
Promoting the model to the default made it the substrate for netplay rollback,
72+
whose save/restore round-trip exposed a determinism gap: the five in-flight fetch
73+
fields (`octal_latch`, `address_bus`, `ale_armed`, `pattern_latch_stale`,
74+
`copy_v_delay`) were not serialized, so a mid-render checkpoint could restore them
75+
wrong. They are now serialized in a new **`PPU_SNAPSHOT_VERSION` v5 tail** — an
76+
**additive** change (pre-v5 `.rns` still load, upconverting to inactive rest
77+
defaults), the same pattern the v3 / v4 tails used. All 16 netplay
78+
rollback-determinism tests pass. A v2.0.3 save-state is **forward-incompatible**
79+
(will not load on <= v2.0.2), but this is an in-train sub-version bump, **not** an
80+
ADR-0028 `.rns` format-epoch break.
81+
82+
## Harbor Android polish + foss/play monetization glue (ADR 0025)
83+
84+
- **Monetization glue (step 5) wired into the `play` flavor:** a real
85+
`MonetizationGate` (AppLovin MAX ad glue + RevenueCat 8.10.0 entitlement wrapper),
86+
`feature_enabled` gating, the run-out paywall + countdown Compose overlay,
87+
`begin_session` at launch, `export/restore_progress` persistence, and the
88+
offline-grace path — all driving the Rust `AdPolicy` core from
89+
`rustynes-monetization`. The **`foss` flavor keeps a no-op twin**, so the default
90+
F-Droid artifact is behaviour-identical. Both flavors assemble; monetization is
91+
behaviourally **dormant** pending on-device runtime verification (v2.0.9).
92+
- **R8 full-mode keeps** re-tuned for the monetization UniFFI package + AppLovin +
93+
RevenueCat; **Baseline Profile / vitals** plumbing carried forward with a
94+
maintainer note that on-device regeneration against the v2.0.0 core is a v2.0.9
95+
step (no device in the build environment).
96+
- **Host-localizable bridge warnings:** `rustynes-mobile` gains a UniFFI-exported
97+
`HostWarning` enum + `drain_warning_codes()` + `host_warning_message()`;
98+
`drain_warnings()` is preserved verbatim, so existing hosts are unbroken.
99+
100+
## Dependencies
101+
102+
No workspace dependency changes this release (the v2.0.1 sweep carries forward).
103+
The `play` Android flavor adds AppLovin MAX + RevenueCat via `playImplementation`
104+
(the `foss` artifact links neither).
105+
106+
---
107+
108+
## What's next — the rest of "Harbor"
109+
110+
- **v2.0.4** — Android RC + the foss/play finalization-window closeout.
111+
- **v2.0.5–v2.0.8** — iOS finalization on the v2.0.0 core.
112+
- **v2.0.9** — joint on-device readiness verification (Baseline Profile capture,
113+
vitals, foldable WYSIWYG, live monetization runtime) for both apps.
114+
- **v2.1.0** — the **joint Google Play + Apple App Store + AltStore PAL + F-Droid
115+
launch**.
116+
117+
Full detail: `CHANGELOG.md` `[2.0.3]` · `docs/STATUS.md` (single source of truth) ·
118+
`docs/adr/0030-accuracycoin-ale-read-hybrid-addresses-octal-latch.md` ·
119+
`to-dos/plans/v2.0.3-2cycle-ale-plan.md`.

‎CHANGELOG.md‎

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

18-
*Nothing yet — the next planned milestone is **v2.0.3**, continuing the v2.0.x
19-
"Harbor" mobile-finalization train: it reworks the octal-latch Hybrid path from
20-
the `+1 coarse-X` approximation to a first-principles latch-carry model, runs the
21-
60-ROM commercial byte-identity oracle, and **promotes `mc-ppu-bus-addr-hybrid` to
22-
default** (shipped AccuracyCoin 141/141) — see `to-dos/ROADMAP.md` and ADR 0030.*
18+
## [2.0.3] - 2026-07-08 - "Harbor" (2-cycle-ALE promoted to default — shipped AccuracyCoin 141/141)
19+
20+
### Added
21+
22+
- **Android foss/play monetization glue (ADR 0025 flavor split, step 5).** The
23+
`play` flavor gains its full monetization surface — a real `MonetizationGate`
24+
(AppLovin MAX interstitial/rewarded ad glue via `AdGate`/`RewardedGate` +
25+
RevenueCat entitlement wrapper `RcBilling`, on the RevenueCat 8.10.0 API), the
26+
`feature_enabled` gating hook, the run-out paywall + `mm:ss` countdown Compose
27+
overlay, `begin_session`-at-launch, `export_progress`/`restore_progress`
28+
persistence, and the offline-grace path — all owning the Rust `AdPolicy` core from
29+
`rustynes-monetization`. The **`foss` flavor keeps a no-op `MonetizationGate` twin**
30+
(every feature free, no ads, never touches the FFI ad core), so the default F-Droid
31+
artifact stays behaviour-identical. Ad SDKs, the `AD_ID`/AdMob manifest entries, and
32+
the paywall strings (en/es) live in `src/play/` only; the R8 full-mode keep rules
33+
cover the monetization UniFFI package + AppLovin + RevenueCat. Monetization remains
34+
behaviourally **dormant** pending on-device runtime verification (v2.0.9); the glue
35+
is structurally complete and both flavors assemble. Android `versionCode 20001 ->
36+
20003`, `versionName -> "2.0.3"`.
37+
- **Host-localizable mobile bridge warnings.** `rustynes-mobile` gains a
38+
UniFFI-exported `HostWarning` enum (first variant: `PreTimebaseMovie`, the ADR 0028
39+
`.rnm` pre-Timebase movie warning) plus an additive `drain_warning_codes() ->
40+
Vec<HostWarning>` and an exported `host_warning_message()`, so Kotlin/Swift hosts can
41+
render localized strings instead of the baked-in English. The existing
42+
`drain_warnings() -> Vec<String>` is preserved verbatim (it now maps each code to its
43+
English `message()`), so current hosts are unbroken; desktop/wasm parity is unchanged.
44+
45+
### Changed
46+
47+
- **PPU: the 2-cycle-ALE fetch model is now the shipped default and the only PPU
48+
fetch path (ADR 0030).** The first-principles latch-carry model (ALE-drive +
49+
first-class octal latch + multiplexed-bus splice + delayed-`CopyV` "Hybrid
50+
Addresses" + `$2007`-ALE-overlap "ALE + Read") is promoted from the experimental
51+
`mc-ppu-2cycle-ale` flag to unconditional, following the v2.0.0 precedent of
52+
promoting the one-clock scheduler by deleting its flag. **Shipped AccuracyCoin is
53+
now 141/141 (100%)** — both the "ALE + Read" (`$0491`) and "Hybrid Addresses"
54+
(`$0492`) PPU tests pass on the default build (previously 139/141). Two commercial
55+
titles now render more TriCNES-faithfully at a mid-render `$2006` scroll write —
56+
**Super Mario Bros. 3** (a single leftmost tile on scanline 194) and **Uchuu
57+
Keibitai SDF** (one tile at the MMC5 split boundary on scanline 15); no other
58+
commercial title's output changed. The `scanline.nes` visual-regression golden was
59+
likewise re-blessed for the same mid-render `$2006` reason (it is the one public
60+
test ROM in the suite that drives mid-scanline scroll/timing; the other eight visual
61+
goldens — including the NMI-drift-sensitive `nmi_sync` demo — stayed byte-identical,
62+
confirming the change is surgical to the `$2006`-mid-render path). Headless frame cost
63+
rises ~10% (nestest ~4.15 ms/frame, still ~4× realtime); accepted for the accuracy gain.
64+
65+
### Removed
66+
67+
- **Both experimental octal-latch flags are retired:** `mc-ppu-2cycle-ale` (the now-
68+
default model) and `mc-ppu-bus-addr-hybrid` (the superseded v2.0.2 whole-dot
69+
`+1 coarse-X` stand-in), across `rustynes-ppu`/`rustynes-core`/
70+
`rustynes-test-harness`. The v2.0.2 stand-in code (the `octal_effective` splice
71+
function, `OctalFetch` enum, the `copy_v` one-shot, and the coarse-X
72+
reconstruction) is deleted. The `octal_trace` calibration ring is kept behind a
73+
new default-off dev feature `ppu-octal-trace` (it does not gate emulation
74+
behavior; off, `push` is a no-op stub with no 64-bit-atomic storage, keeping the
75+
`no_std` chip stack building).
76+
77+
### Fixed
78+
79+
- **Netplay rollback determinism on the new fetch model.** Promoting 2-cycle-ALE to
80+
the default path made it the substrate for netplay rollback, whose save/restore
81+
round-trip exposed a determinism gap: a mid-render checkpoint could land with the
82+
in-flight fetch state (`octal_latch`, `address_bus`, `ale_armed`,
83+
`pattern_latch_stale`, `copy_v_delay`) live, and those fields were not serialized,
84+
desyncing the re-simulated frame. They are now serialized in a new
85+
**`PPU_SNAPSHOT_VERSION` v5 tail** — an ADDITIVE change (pre-v5 `.rns` still load,
86+
upconverting the fields to their inactive rest defaults), the same pattern the v3
87+
(`$2007` state machine) and v4 (overclock countdown) tails already used. All 16
88+
`rustynes-netplay` rollback-determinism tests pass. **Upgrade note:** a v2.0.3
89+
save-state (PPU sub-version v5) is forward-incompatible — it will not load on
90+
<= v2.0.2 — but this is an in-train additive sub-version bump, *not* an ADR-0028
91+
`.rns` format-epoch break; older save-states continue to load on v2.0.3.
2392

2493
## [2.0.2] - 2026-07-08 - "Harbor" (octal-latch PPU model — AccuracyCoin 141/141 flag-on)
2594

‎Cargo.lock‎

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

‎Cargo.toml‎

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

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

0 commit comments

Comments
 (0)