diff --git a/.markdownlintignore b/.markdownlintignore
index 58b9bf8f..014459c6 100644
--- a/.markdownlintignore
+++ b/.markdownlintignore
@@ -21,8 +21,3 @@ to-dos/archive/
# that fed them — reference, not maintained prose, kept exactly as authored
# (like ref-docs/).
to-dos/plans/
-
-# Harvested mobile-monetization reference set (from the rustynes-monetization
-# scaffold, 2026-06-23) — reference design docs kept largely as authored, with a
-# reconciliation banner in the index. Reference, not maintained prose (like ref-docs/).
-docs/monetization/
diff --git a/AGENTS.md b/AGENTS.md
index 1f4eebdc..8b982567 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,13 +27,13 @@
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`).
-**Current release: v2.2.5 "Colophon"** (2026-08-03) — a **provenance, licensing, and documentation-integrity** release with **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction). It reworded in-source comments that had mischaracterized publicly-documented hardware-behavior implementations as "ports of" copyleft emulators (Mesen2 GPLv3, puNES GPLv2) into the accurate oracle framing; rewrote `NOTICE` to disclose the behavioral-oracle use of GPL emulators (Mesen2/MesenCE, higan, GeraNES, ares, FCEUX, Nestopia, puNES — no code incorporated) and to attribute the genuinely-incorporated permissive components (emu2413, TriCNES, rcheevos — all MIT), the bundled fonts and test ROMs, and the CRT-shader/NTSC-filter visual influences as independent reimplementations; disclosed **GeraNES (GPL-3.0-only)** as an oracle; added `docs/originality-and-provenance.md`; and added an AI-assistance disclosure to the README (removing a misleading comparison graphic and fixing a mislabeled screenshot caption). On top of **v2.2.4 "Cartridge"** (2026-07-24) — a **libretro / RetroArch distribution** cut whose purpose is that the RustyNES core **builds and installs cleanly through the Libretro buildbot** () for in-RetroArch use. **Zero emulation-core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.3, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. `crates/rustynes-libretro` wraps `rustynes-core`, so it inherits every v2.2.3 change automatically (the fast-dot-path default; the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema, transparent because `get_serialize_size` / `on_serialize` size and emit the *current* snapshot via `Nes::snapshot_core_into` rather than a fixed layout; the `Mapper::mix_audio` i32 widening; the Zapper model; the `mNNN_` mapper rename), and both buildbot cross-ABIs the CI early-warning gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — `cargo check --release -p rustynes-libretro` clean. The concrete change is a **`rustynes_libretro.info` metadata correction**: **`disk_control` `false` → `true`** (the real fix — the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed but was advertised as absent, hiding multi-disk FDS swapping from RetroArch's Quick Menu), `display_version` `v1.0.0` → `v2.2.4`, and the description mapper count `168` → `172`. Libretro **core options** (region / overscan / palette / accuracy toggles) remain unexposed — `core_options = "false"` is accurate, a documented future enhancement rather than a v2.2.4 gap. The Antigravity PR reviewer standardization onto the shared template rides along. On top of **v2.2.3 "Datum"** (2026-07-23) — a **performance and accuracy-closure patch**, the product of a measure-first appraisal that profiled the emulator and acted on what the profile showed rather than on intuition. **Performance:** the specialized PPU fast dot path is promoted to the **default** and exposed to users for the first time — `Nes::set_fast_dotloop` had **no caller outside the core**, so a **−11.3%** frame-time win (fresh clean-host Criterion, reproducing v2.1.8's +12.3% by a different method; differential-tested bit-identical every frame since v2.1.8) shipped switched off and unreachable; release builds now ship **PGO-optimized** Linux binaries when the existing >3%-and-byte-identical gate passes; and CI gained a same-runner **relative** frame-time regression gate, closing a hole where a 2.5x slowdown passed the deliberately-loose absolute ceiling. **Two optimizations were measured and REJECTED** and are documented with their numbers per `docs/performance.md`'s convention — P3 (`emit_pixel` bounds-check elision) made the shipped default *slower* (+4.32% / +3.35% on the `_fast` workloads, p ≤ 0.02), and P4 (`cpu_clock`) found both textbook optimizations already implemented with the one remaining lever capped at **≤1.9%**. **Accuracy:** the **last two Holy Mapperel residuals are closed**, so all 17 ROMs report `detail=0000` (was 15/17) — MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's CHR-register layer, gated on `chr_is_ram`) and FME-7's open bus on the RAM-selected-but-disabled window, both routed through the trait's existing `cpu_read_unmapped` contract. MMC1 is the change Holy Mapperel's README calls a game-compatibility hazard (FCEUX / PowerPak omit it), so it was validated before landing: **60/60** commercial ROMs including seven battery-backed MMC1 saves, plus **138/138** extended. The **Sunsoft 5B absolute level** is calibrated against Mesen2, which required widening `Mapper::mix_audio` to `i32` (the correct full-scale 5B tone `1882 * 18.471 = 34,761` does not fit `i16`). A **save-state schema gap** is fixed — `PPU_SNAPSHOT_VERSION` **8** carrying the sprite-eval FSM + OAM data-bus state, plus an APU **v4** tail — which is what made AccuracyCoin report **141/141 through run-ahead** as well as without it; a new standing field-vs-schema audit found it and the two APU gaps mechanically. A **Zapper beam-relative light model** lands opt-in / default-off (no pass-fail light-gun ROM exists to adjudicate it). **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff. Also: the eleven `sprintN.rs` mapper modules (27,631 lines, ~110 boards) are renamed for the boards they emulate with `mNNN_` mapper-number prefixes, proven content-preserving by a byte-for-byte item comparison (930 items, 0 altered) and an identical 172-ID dispatch table.
+**Current release: v2.2.6 "Almanac"** (2026-08-04) — a **de-monetization + provenance** release opening the **v2.2.6 → v2.3.0** NESdev-remediation line. **RustyNES is permanently open-source and income-free (ADR 0035)**: all planned monetization is removed (the `rustynes-monetization` crate, `docs/monetization/`, and the Android/iOS billing / ad / freemium / paywall layers deleted) and the native apps are kept as **free FOSS apps** (no ads, no tracking, no paid unlock; the free Google-Play services + `foss`/`play` split retained). It also discloses (ADR 0030) that the PPU hybrid-address *timing* was calibrated to TriCNES (reproducing the Rad Racer mis-render), flagged for a documentation-derived rework in v2.3.0. **Zero emulation-core behavior changes**, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. On top of **v2.2.5 "Colophon"** (2026-08-03) — a **provenance, licensing, and documentation-integrity** release with **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction). It reworded in-source comments that had mischaracterized publicly-documented hardware-behavior implementations as "ports of" copyleft emulators (Mesen2 GPLv3, puNES GPLv2) into the accurate oracle framing; rewrote `NOTICE` to disclose the behavioral-oracle use of GPL emulators (Mesen2/MesenCE, higan, GeraNES, ares, FCEUX, Nestopia, puNES — no code incorporated) and to attribute the genuinely-incorporated permissive components (emu2413, TriCNES, rcheevos — all MIT), the bundled fonts and test ROMs, and the CRT-shader/NTSC-filter visual influences as independent reimplementations; disclosed **GeraNES (GPL-3.0-only)** as an oracle; added `docs/originality-and-provenance.md`; and added an AI-assistance disclosure to the README (removing a misleading comparison graphic and fixing a mislabeled screenshot caption). On top of **v2.2.4 "Cartridge"** (2026-07-24) — a **libretro / RetroArch distribution** cut whose purpose is that the RustyNES core **builds and installs cleanly through the Libretro buildbot** () for in-RetroArch use. **Zero emulation-core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.3, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. `crates/rustynes-libretro` wraps `rustynes-core`, so it inherits every v2.2.3 change automatically (the fast-dot-path default; the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema, transparent because `get_serialize_size` / `on_serialize` size and emit the *current* snapshot via `Nes::snapshot_core_into` rather than a fixed layout; the `Mapper::mix_audio` i32 widening; the Zapper model; the `mNNN_` mapper rename), and both buildbot cross-ABIs the CI early-warning gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — `cargo check --release -p rustynes-libretro` clean. The concrete change is a **`rustynes_libretro.info` metadata correction**: **`disk_control` `false` → `true`** (the real fix — the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed but was advertised as absent, hiding multi-disk FDS swapping from RetroArch's Quick Menu), `display_version` `v1.0.0` → `v2.2.4`, and the description mapper count `168` → `172`. Libretro **core options** (region / overscan / palette / accuracy toggles) remain unexposed — `core_options = "false"` is accurate, a documented future enhancement rather than a v2.2.4 gap. The Antigravity PR reviewer standardization onto the shared template rides along. On top of **v2.2.3 "Datum"** (2026-07-23) — a **performance and accuracy-closure patch**, the product of a measure-first appraisal that profiled the emulator and acted on what the profile showed rather than on intuition. **Performance:** the specialized PPU fast dot path is promoted to the **default** and exposed to users for the first time — `Nes::set_fast_dotloop` had **no caller outside the core**, so a **−11.3%** frame-time win (fresh clean-host Criterion, reproducing v2.1.8's +12.3% by a different method; differential-tested bit-identical every frame since v2.1.8) shipped switched off and unreachable; release builds now ship **PGO-optimized** Linux binaries when the existing >3%-and-byte-identical gate passes; and CI gained a same-runner **relative** frame-time regression gate, closing a hole where a 2.5x slowdown passed the deliberately-loose absolute ceiling. **Two optimizations were measured and REJECTED** and are documented with their numbers per `docs/performance.md`'s convention — P3 (`emit_pixel` bounds-check elision) made the shipped default *slower* (+4.32% / +3.35% on the `_fast` workloads, p ≤ 0.02), and P4 (`cpu_clock`) found both textbook optimizations already implemented with the one remaining lever capped at **≤1.9%**. **Accuracy:** the **last two Holy Mapperel residuals are closed**, so all 17 ROMs report `detail=0000` (was 15/17) — MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's CHR-register layer, gated on `chr_is_ram`) and FME-7's open bus on the RAM-selected-but-disabled window, both routed through the trait's existing `cpu_read_unmapped` contract. MMC1 is the change Holy Mapperel's README calls a game-compatibility hazard (FCEUX / PowerPak omit it), so it was validated before landing: **60/60** commercial ROMs including seven battery-backed MMC1 saves, plus **138/138** extended. The **Sunsoft 5B absolute level** is calibrated against Mesen2, which required widening `Mapper::mix_audio` to `i32` (the correct full-scale 5B tone `1882 * 18.471 = 34,761` does not fit `i16`). A **save-state schema gap** is fixed — `PPU_SNAPSHOT_VERSION` **8** carrying the sprite-eval FSM + OAM data-bus state, plus an APU **v4** tail — which is what made AccuracyCoin report **141/141 through run-ahead** as well as without it; a new standing field-vs-schema audit found it and the two APU gaps mechanically. A **Zapper beam-relative light model** lands opt-in / default-off (no pass-fail light-gun ROM exists to adjudicate it). **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff. Also: the eleven `sprintN.rs` mapper modules (27,631 lines, ~110 boards) are renamed for the boards they emulate with `mNNN_` mapper-number prefixes, proven content-preserving by a byte-for-byte item comparison (930 items, 0 altered) and an identical 172-ID dispatch table.
The prior release, **v2.2.2 "Conduit"** (2026-07-21), was a **build, distribution, and CI-integrity patch**: the **libretro buildbot recipe from 1 of 10 jobs green to all ten building** (the last step before RustyNES lands in RetroArch's built-in core downloader), a **GitHub Actions supply-chain hardening** pass (`persist-credentials: false` on all 19 checkouts, a fail-closed release-tag check via `git/matching-refs`, `dtolnay/rust-toolchain` SHA-pinned off `@master`), and the toolchain **collapsed to one pinned source of truth** — no toolchain version literal anywhere under `.github/` and **no `nightly` on any build path**. **Zero emulation-core changes**, so AccuracyCoin held 141/141 by construction. Its one behavioural improvement in a shipped artifact: the libretro **tvOS** core built with `panic = "abort"` like every other platform.
The prior release, **v2.2.1** (2026-07-15), was a **housekeeping patch** on top of v2.2.0 "Capstone" (next paragraph): archives two batches of dev/research tooling (the Game Genie header-robust re-key's six research/verification scripts in `scripts/gg/`, and the 2A03-revision DMA-divergence probe in `scripts/probes/`), consolidates six open Dependabot PRs with **zero source changes** (`pollster` 0.4→1.0, `wide` 0.7→1.5, `tungstenite`/`tokio-tungstenite` 0.29→0.30, `bytemuck`/`cc` patch, `actions/setup-python` v5→v6), and wires four gitignored, `RUSTYNES_FDS_BIOS`-gated smoke tests against `TakuikaNinja`'s FDS `$4023` / mirroring / audio-register / DRAM-watchdog hardware-verification probes (regression insurance for behavior RustyNES already models correctly, not a fix — the `$4030.D1` DRAM-refresh-watchdog probe tracks a known, honest residual RustyNES does not model, per `docs/accuracy-ledger.md`). **Zero accuracy, feature, or core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay-replay formats, and every golden vector are untouched; **AccuracyCoin holds 141/141 (100.00%)**, unchanged from v2.2.0.
-The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut** that **closes the v2.1.5 → v2.2.0 "deepen the existing project"** run, landing its two remaining marquees — the **netplay matchmaking / lobby** stack and the **FDS medium model** — atop a peripherals + quality/security pass, all sitting on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. Every v2.2.0 change is **additive or default-off**, so the deterministic core is untouched on the shipped default — **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and `blargg_apu_2005` / `pal_apu_tests` (10/10) / `visual_regression` / the 60-ROM commercial oracle / the `#![no_std]` chip stack are all unchanged, and save-state / TAS / netplay replay stay bit-identical. It lands (PRs #290, #291; cut #297): a **netplay lobby + matchmaking** stack over the existing room-code / TURN transport (`ListRooms`/`RoomList` browse-and-join room directory + a server-side `QuickMatch`/`Matched` quick-play path, both **signaling only** with the `room-list` frame parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` = 256); **delayed-stream spectators** (`SpectatorConfig.delay_frames`, clamped to `MAX_DELAY_FRAMES` = 512 — a pure presentation hold that cannot perturb the match); a **hardened desync + peer-liveness surface** (a graded `DesyncStatus` with a hysteresis threshold of 3 ≈ 1.5 s + a graded `PeerLink` / terminal `DisconnectReason::PeerTimeout` on multi-second 2 s / 5 s RTT timeouts — telemetry only, the rollback/determinism contract untouched); the **FDS medium model completion** (F4.3, a marquee — a per-block **CRC-16/KERMIT** re-emitted on every BIOS write via `resynth_block_crc` over a synthesized gap/mark wire image, an opt-in default-OFF continuous analog head-seek / velocity model `Fds::set_analog_head_seek` replacing the flat `HEAD_RESEEK_CYCLES`, and a BIOS-free synthetic write-verify oracle `Fds::medium_write_verify` as the CI-verifiable half — the real-BIOS write-CRC path stays gitignored/local; an additive **v4** FDS save-state tail, byte-identical with the model off); **peripherals** (a Famicom `$4016`-bit-2 **microphone** `Nes::set_microphone` — `$4016`-only, never `$4017` — and a 3×3-aperture Zapper light-timing model `ZAPPER_APERTURE_*`, both additive / default-off); and a **quality / security** pass (cargo-fuzz targets grown **3 → 8** — `ppu_reg_io` / `apu_reg_io` / `netplay_message` / `save_state` / `movie` — the `movie` target finding and fixing **two real OOM-DoS paths** in `Movie::deserialize`, byte-identical for valid input; a read-only **Tools → ROM Info** browser over `&Nes` that never mutates; and four new MkDocs handbook pages). The prior step — **v2.1.10 "Fathom" ("Loom")** (2026-07-12) — was the **creator-tools & web-parity** step (TAStudio greenzone depth + Lua API breadth + a browser RetroAchievements auth-proxy deploy stack (ADR 0015) + Vs. `DualSystem` presentation in the libretro core; the wasm desktop-style dual present remains deferred). The intervening steps: **v2.1.6 "Timbre"** (2026-07-11) expansion-audio fidelity (a decibel oracle asserting measured level ratios vs Mesen2 / hardware targets, hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical) → **v2.1.7 "Stepping"** (2026-07-12) hardware revisions & DMA frontier (opt-in `PpuRevision` / `Cpu2A03Revision` die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier proven a **documented no-op on every oracle**, ADR 0033 — honest, not faked) → **v2.1.8 "Tempo"** (2026-07-12) performance (a default-OFF specialized fast PPU dot path, `Nes::set_fast_dotloop` — ~+12.3% rendering-heavy, differential-tested bit-identical; a SIMD-validated software blitter where scalar stays default; a wasm 3.99 MiB-gzip size pass) → **v2.1.9 "Aperture"** (2026-07-12) presentation & signal (a raw NTSC composite `rustynes-ppu::raw_signal` core; a marquee CRT shader stack — CRT-Royale / CRT Guest Advanced / Sony Megatron — all naga-validated WGSL; GIF/WAV capture; a live generated-palette preview). Now on the shipped default the Fathom line keeps the deterministic core byte-identical and layers accuracy + display + quality-of-life work atop the v2.0.0 core: **v2.1.0** accuracy remediation (PPU palette backdrop-override, the mapper-tier completion to **51 Core / 95 Curated / 26 BestEffort of 172**, the MMC3 R1/R2 residual closed by-design-permanent) → **v2.1.1** the Wizards & Warriors game-DB mirroring freeze fix → **v2.1.2 "Prism"** display fidelity (Vs. `DualSystem` desktop second-screen, the NTSC composite-shader ladder, an in-core generated NTSC palette, NSF non-60 Hz + NSFe) → **v2.1.3 "Codex"** quality-of-life (an APU audio filter-model selector, Game Genie code nomination + a full / header-robust code database, a Material-for-MkDocs handbook served at `/docs/` on GitHub Pages) → **v2.1.4 "Caliper"** accuracy hardening (opt-in default-OFF OAM decay modeled on Mesen2, a CI boot-smoke sweep of all 26 BestEffort mapper families, a shared MMC3-clone A12/IRQ timing oracle) → **v2.1.5 "Vernier"** the regression-net & residual step → **v2.1.6 "Timbre"** expansion-audio fidelity → **v2.1.7 "Stepping"** hardware revisions & DMA frontier → **v2.1.8 "Tempo"** performance → **v2.1.9 "Aperture"** presentation & signal → **v2.1.10 "Loom"** creator tools & web parity → **v2.2.0 "Capstone"** the milestone cut closing the run → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch (the prior release). v2.0.0 "Timebase" remains RustyNES's designated MAJOR-boundary release — see "Timebase (v2.0.0)" below. RustyNES is now a multi-platform emulation suite, all on the one byte-identical cycle-accurate core — **`docs/STATUS.md` is the authoritative current-state record.** What ships beyond the desktop app:
+The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut** that **closes the v2.1.5 → v2.2.0 "deepen the existing project"** run, landing its two remaining marquees — the **netplay matchmaking / lobby** stack and the **FDS medium model** — atop a peripherals + quality/security pass, all sitting on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. Every v2.2.0 change is **additive or default-off**, so the deterministic core is untouched on the shipped default — **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and `blargg_apu_2005` / `pal_apu_tests` (10/10) / `visual_regression` / the 60-ROM commercial oracle / the `#![no_std]` chip stack are all unchanged, and save-state / TAS / netplay replay stay bit-identical. It lands (PRs #290, #291; cut #297): a **netplay lobby + matchmaking** stack over the existing room-code / TURN transport (`ListRooms`/`RoomList` browse-and-join room directory + a server-side `QuickMatch`/`Matched` quick-play path, both **signaling only** with the `room-list` frame parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` = 256); **delayed-stream spectators** (`SpectatorConfig.delay_frames`, clamped to `MAX_DELAY_FRAMES` = 512 — a pure presentation hold that cannot perturb the match); a **hardened desync + peer-liveness surface** (a graded `DesyncStatus` with a hysteresis threshold of 3 ≈ 1.5 s + a graded `PeerLink` / terminal `DisconnectReason::PeerTimeout` on multi-second 2 s / 5 s RTT timeouts — telemetry only, the rollback/determinism contract untouched); the **FDS medium model completion** (F4.3, a marquee — a per-block **CRC-16/KERMIT** re-emitted on every BIOS write via `resynth_block_crc` over a synthesized gap/mark wire image, an opt-in default-OFF continuous analog head-seek / velocity model `Fds::set_analog_head_seek` replacing the flat `HEAD_RESEEK_CYCLES`, and a BIOS-free synthetic write-verify oracle `Fds::medium_write_verify` as the CI-verifiable half — the real-BIOS write-CRC path stays gitignored/local; an additive **v4** FDS save-state tail, byte-identical with the model off); **peripherals** (a Famicom `$4016`-bit-2 **microphone** `Nes::set_microphone` — `$4016`-only, never `$4017` — and a 3×3-aperture Zapper light-timing model `ZAPPER_APERTURE_*`, both additive / default-off); and a **quality / security** pass (cargo-fuzz targets grown **3 → 8** — `ppu_reg_io` / `apu_reg_io` / `netplay_message` / `save_state` / `movie` — the `movie` target finding and fixing **two real OOM-DoS paths** in `Movie::deserialize`, byte-identical for valid input; a read-only **Tools → ROM Info** browser over `&Nes` that never mutates; and four new MkDocs handbook pages). The prior step — **v2.1.10 "Fathom" ("Loom")** (2026-07-12) — was the **creator-tools & web-parity** step (TAStudio greenzone depth + Lua API breadth + a browser RetroAchievements auth-proxy deploy stack (ADR 0015) + Vs. `DualSystem` presentation in the libretro core; the wasm desktop-style dual present remains deferred). The intervening steps: **v2.1.6 "Timbre"** (2026-07-11) expansion-audio fidelity (a decibel oracle asserting measured level ratios vs Mesen2 / hardware targets, hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical) → **v2.1.7 "Stepping"** (2026-07-12) hardware revisions & DMA frontier (opt-in `PpuRevision` / `Cpu2A03Revision` die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier proven a **documented no-op on every oracle**, ADR 0033 — honest, not faked) → **v2.1.8 "Tempo"** (2026-07-12) performance (a default-OFF specialized fast PPU dot path, `Nes::set_fast_dotloop` — ~+12.3% rendering-heavy, differential-tested bit-identical; a SIMD-validated software blitter where scalar stays default; a wasm 3.99 MiB-gzip size pass) → **v2.1.9 "Aperture"** (2026-07-12) presentation & signal (a raw NTSC composite `rustynes-ppu::raw_signal` core; a marquee CRT shader stack — CRT-Royale / CRT Guest Advanced / Sony Megatron — all naga-validated WGSL; GIF/WAV capture; a live generated-palette preview). Now on the shipped default the Fathom line keeps the deterministic core byte-identical and layers accuracy + display + quality-of-life work atop the v2.0.0 core: **v2.1.0** accuracy remediation (PPU palette backdrop-override, the mapper-tier completion to **51 Core / 95 Curated / 26 BestEffort of 172**, the MMC3 R1/R2 residual closed by-design-permanent) → **v2.1.1** the Wizards & Warriors game-DB mirroring freeze fix → **v2.1.2 "Prism"** display fidelity (Vs. `DualSystem` desktop second-screen, the NTSC composite-shader ladder, an in-core generated NTSC palette, NSF non-60 Hz + NSFe) → **v2.1.3 "Codex"** quality-of-life (an APU audio filter-model selector, Game Genie code nomination + a full / header-robust code database, a Material-for-MkDocs handbook served at `/docs/` on GitHub Pages) → **v2.1.4 "Caliper"** accuracy hardening (opt-in default-OFF OAM decay modeled on Mesen2, a CI boot-smoke sweep of all 26 BestEffort mapper families, a shared MMC3-clone A12/IRQ timing oracle) → **v2.1.5 "Vernier"** the regression-net & residual step → **v2.1.6 "Timbre"** expansion-audio fidelity → **v2.1.7 "Stepping"** hardware revisions & DMA frontier → **v2.1.8 "Tempo"** performance → **v2.1.9 "Aperture"** presentation & signal → **v2.1.10 "Loom"** creator tools & web parity → **v2.2.0 "Capstone"** the milestone cut closing the run → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch. v2.0.0 "Timebase" remains RustyNES's designated MAJOR-boundary release — see "Timebase (v2.0.0)" below. RustyNES is now a multi-platform emulation suite, all on the one byte-identical cycle-accurate core — **`docs/STATUS.md` is the authoritative current-state record.** What ships beyond the desktop app:
- **Timebase (v2.0.0)** — the scheduler substrate is rewritten from a five-counter dot-lockstep model to a single canonical cycle counter, every CPU cycle a real bus access, and a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up (ADR 0002 / ADR 0029), now the *only* scheduler path. This is a MAJOR-boundary breaking change (ADR 0003): `.rns` save-state and `.rnm` movie format epochs bump (ADR 0028) — a pre-v2.0.0 `.rns` slot now fails to load with a clear error instead of silently misinterpreting stale bytes. Landed across five betas + rc.1 (PRs #217–223). Also new: core-level **Vs. `DualSystem`** dual-console support (`Emu::Dual`, `crates/rustynes-core`) for the four Vs. arcade cabinet boards — core-and-test-harness-only, frontend wiring deferred. The R1/R2 MMC3 IRQ-timing residual is by-design-deferred beyond this release with a mechanism-level finding recorded in ADR 0002 (not closed, not silently dropped). **AccuracyCoin now measures 141/141 (100.00%)**: the v2.0.1 upstream AccuracyCoin re-sync grew the catalog to 146 rows / 141 assigned tests and briefly opened two new PPU gaps ("ALE + Read" $0491, "Hybrid Addresses" $0492), which **v2.0.3** closed by promoting the 2-cycle-ALE PPU fetch model to the unconditional default (both experimental flags retired; additive `PPU_SNAPSHOT_VERSION` v5 tail). AccuracyCoin held 100% (139/139) throughout the v2.0.0 betas and final cut, dipped to 139/141 under the v2.0.1 re-sync, and is back to a full 141/141 from v2.0.3 onward.
@@ -43,7 +43,7 @@ The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut**
- **Mapper breadth → 172 families** (up from 168 at the v1.7.x tag), Core / Curated / BestEffort behind the CI accuracy-honesty gate.
- **Release automation** — `.github/workflows/release-auto.yml`: when a new version goes final-green on `main`, it auto-tags + publishes the GitHub Release (body from a maintainer-authored `.github/release-notes/vX.Y.Z.md` override, else the CHANGELOG `[X.Y.Z]` section; title codename parsed from the CHANGELOG header) and builds + attaches the desktop binaries by invoking `release.yml` via `workflow_call` (a tag pushed by `GITHUB_TOKEN` can't trigger `on: push: tags`, hence the direct call). The v1.8.0–v1.9.9 GitHub Releases are all published with comprehensive notes + Linux / macOS-aarch64 / Windows binaries.
-Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch, **v2.2.2 "Conduit"**, **v2.2.3 "Datum"**, and **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut), the current release; see the "Current release" paragraph above. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — with it the `rustynes-monetization` activation — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets).
+Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch, **v2.2.2 "Conduit"**, **v2.2.3 "Datum"**, and **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut), the current release; see the "Current release" paragraph above. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — shipping as a free FOSS app, no ads/tracking/paid unlock, per ADR 0035 — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets).
---
@@ -159,7 +159,7 @@ These cross-cutting decisions span multiple files. Reading individual chip docs
## Where things live
-- `crates/rustynes-{cpu,ppu,apu,mappers,core,netplay,cheevos,frontend,test-harness}/` — the core emulation stack; crate name = dir name. The binary is `rustynes` (in `rustynes-frontend`). Plus the supporting crates: `rustynes-script` (Lua), `rustynes-ra` (RetroAchievements session state), `rustynes-gfx-shaders` (shared WGSL), `rustynes-hdpack` (HD-pack loader/compositor + HD audio), `rustynes-monetization` (dormant until v2.3.0), and the **platform crates** `rustynes-mobile` (the UniFFI bridge — generates Kotlin *and* Swift), `rustynes-android` (JNI/NDK host), `rustynes-ios` (Metal + CoreAudio shim; only the `#[cfg(target_os="ios")]` glue is iOS-specific), and `rustynes-libretro` (the RetroArch core; builds the platform-appropriate `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll`). The `android/` and `ios/` dirs hold the Compose / SwiftUI apps.
+- `crates/rustynes-{cpu,ppu,apu,mappers,core,netplay,cheevos,frontend,test-harness}/` — the core emulation stack; crate name = dir name. The binary is `rustynes` (in `rustynes-frontend`). Plus the supporting crates: `rustynes-script` (Lua), `rustynes-ra` (RetroAchievements session state), `rustynes-gfx-shaders` (shared WGSL), `rustynes-hdpack` (HD-pack loader/compositor + HD audio), and the **platform crates** `rustynes-mobile` (the UniFFI bridge — generates Kotlin *and* Swift), `rustynes-android` (JNI/NDK host), `rustynes-ios` (Metal + CoreAudio shim; only the `#[cfg(target_os="ios")]` glue is iOS-specific), and `rustynes-libretro` (the RetroArch core; builds the platform-appropriate `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll`). The `android/` and `ios/` dirs hold the Compose / SwiftUI apps.
- `docs/` — implementation specs. These are the **spec**, not history: update them in the same PR as the code change. Per-subsystem files (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `cartridge-format.md`, `scheduler.md`) + cross-cutting (`architecture.md`, `testing-strategy.md`, `performance.md`, `frontend.md`, `compatibility.md`). `docs/STATUS.md` is the **single source of truth** for per-suite pass counts, the mapper matrix, and version policy. `docs/adr/` holds Michael-Nygard-format ADRs.
- `ref-docs/` — immutable hardware + emulation reference (60+ source research report). Updates go in dated supplemental files.
- `to-dos/ROADMAP.md` → phase/sprint files — tickets with stable IDs `T-PS-NNN`. Reference in commits.
@@ -185,7 +185,7 @@ These cross-cutting decisions span multiple files. Reading individual chip docs
- `ref-docs/` is immutable. Research updates go in dated supplemental files.
- ADRs go in `docs/adr/` (Michael Nygard format).
- `rustynes-core` re-exports the public types from the chip crates; downstream consumers (`rustynes-frontend`, `rustynes-test-harness`) should depend on `rustynes-core` rather than the chip crates directly.
-- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch (the prior release) — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.5 is released** — in particular the joint mobile app-store launch (Google Play + Apple App Store + AltStore PAL + F-Droid) is the future **v2.3.0** (NOT v2.1.0 or v2.2.0 — the entire v2.1.x line and the v2.2.0 "Capstone" milestone have all already shipped, closing the "deepen the existing project" run; the store launch moved out to v2.3.0 — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
+- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.6 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio, video/gamma, TAS/UX, and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II") is in progress. The freed **v2.3.0** slot is repurposed as that accuracy capstone (NOT a store launch — RustyNES is now income-free per ADR 0035; any free mobile-app store listing is a later, unversioned step with no monetization — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
- **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive.
- The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`.
- **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.39.0`). The local `markdownlint` binary is a newer version that reports rules v0.39.0 lacks (e.g. MD060) — those are NOT gated; verify with `pre-commit run markdownlint --all-files`, not the bare binary. `.markdownlint.json` keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/`
`, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/`, the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those.
@@ -199,10 +199,10 @@ These cross-cutting decisions span multiple files. Reading individual chip docs
- **CI security hardening (PRs #319 + #320, 2026-07-21, merged `85ee20db` / `a69200ef`).** `persist-credentials: false` on **all 19** `actions/checkout` sites (18 in #319; the last one, `release-auto.yml`, once its tag check stopped needing Git credentials — see the next bullet), because build scripts / proc macros / test binaries / Gradle scripts / MkDocs all execute unreviewed PR code that could read the token out of `.git/config`. Facts worth not re-deriving: `.github/actions/rust-setup` performs **no checkout of its own** (so call-site hardening is complete coverage); `persist-credentials` does **not** affect the `gh` CLI or API calls, only git network ops using the stored credential — which is why `gh release create`, `softprops/action-gh-release`, and `fastlane match` (a *different* repo, own `MATCH_GIT_*` secrets) all look like they need it and don't; and the highest-exposure job is **`web.yml`'s `build`**, not any `ci.yml` job, because `web.yml` declares `pages: write` + `id-token: write` at *workflow* level. There are now **no exceptions**: `release-auto.yml`'s `prepare` was the last holdout (it needed `git ls-remote origin` for the tag check), and that check is now a `gh api` call, so its checkout joined the sweep.
- **The release tag-existence check is FAIL-CLOSED — keep it that way.** `release-auto.yml`'s `decide` step queries `gh api repos/$GITHUB_REPOSITORY/git/matching-refs/tags/`, NOT `git/ref/tags/`: `matching-refs` answers "absent" with HTTP 200 + an empty array, so a genuine miss can never be confused with a lookup failure and no error-body parsing is needed. It matches by **prefix**, so the exact ref is compared in `jq` — this is load-bearing, not defensive: `v2.2` prefix-matches two real tags (`v2.2.0`, `v2.2.1`) while exact-matching none. Under `shell: bash` + `set -euo pipefail` both a `gh` failure and a non-array body abort the job (verified: exit 1 and exit 5 respectively). The old `git ls-remote ... >/dev/null 2>&1` read *any* non-zero exit as "tag absent", so a blip would try to re-release a shipped version. **Never reintroduce a form where a failed lookup is indistinguishable from "absent."** (Note when testing shell behavior locally: this harness's shell is zsh, whose `set -e` semantics for `var="$(cmd)"` differ from bash's — test with `bash -c` or you will get a false result.)
- **`dtolnay/rust-toolchain` is SHA-pinned in `.github/actions/rust-setup` — never put it back on a branch ref.** It is `@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1`; it used to be `@master`, a branch that advances on every upstream commit, so each run silently resolved to whatever HEAD was. That composite feeds 12 of the repo's 19 checkouts, including `release.yml` (`contents: write`, builds the shipped binaries) and `web.yml` (`pages: write` + `id-token: write`), and it is the action that *installs the compiler* — the hardest place to notice a compromise. The trailing `# v1` is the form Dependabot's `github-actions` ecosystem (enabled in `.github/dependabot.yml`) reads to keep the pin current, so don't strip it. The other ~12 actions remain on `@vN` tags by choice: those are not expected to move, and blanket SHA-pinning is a maintenance-policy decision, not a drive-by fix.
-- **`pre-commit run --all-files` REWRITES vendored/immutable trees — use `--files ` or a single named hook instead.** `trailing-whitespace` / `end-of-file-fixer` / `mixed-line-ending` *modify* files and, before PR #320, had no `exclude` at all: one `--all-files` run silently reformatted **41 files** across the vendored TriCNES C#, vendored rcheevos C, `ref-docs/`, an upstream font licence, and upstream test-ROM READMEs — destroying exactly the byte-identical-to-upstream property those trees exist for. `.markdownlintignore` covered them for markdownlint only. #320 added a shared `exclude` anchor across the three rewriting hooks, scoped deliberately **narrower** than `.markdownlintignore`: only content we did not author. Frozen-but-ours trees (`docs/archive/`, `to-dos/plans/`, `docs/monetization/`) stay in scope, since the invariant is "don't rewrite what we didn't write". If it happens anyway, revert **only** the unintended paths (never a blanket `git checkout`, and never including your own edits).
+- **`pre-commit run --all-files` REWRITES vendored/immutable trees — use `--files ` or a single named hook instead.** `trailing-whitespace` / `end-of-file-fixer` / `mixed-line-ending` *modify* files and, before PR #320, had no `exclude` at all: one `--all-files` run silently reformatted **41 files** across the vendored TriCNES C#, vendored rcheevos C, `ref-docs/`, an upstream font licence, and upstream test-ROM READMEs — destroying exactly the byte-identical-to-upstream property those trees exist for. `.markdownlintignore` covered them for markdownlint only. #320 added a shared `exclude` anchor across the three rewriting hooks, scoped deliberately **narrower** than `.markdownlintignore`: only content we did not author. Frozen-but-ours trees (`docs/archive/`, `to-dos/plans/`) stay in scope, since the invariant is "don't rewrite what we didn't write". If it happens anyway, revert **only** the unintended paths (never a blanket `git checkout`, and never including your own edits).
- **The libretro buildbot is a THIRD CI system with its own rules — and the pinned toolchain fights it.** The first real run (pipeline #91899, 2026-07-20) passed 1 of 10 jobs. `rust-toolchain.toml`'s `channel = "1.96.0"` makes rustup install a *fresh* toolchain inside libretro's build image, bypassing the image's pre-provisioned cross targets, so 8 jobs died on `E0463: can't find crate for core`; each job in `.gitlab-ci.yml` now runs `rustup target add ${RUST_TARGET}` (NOT added to `rust-toolchain.toml`'s `targets` — that would cost every contributor and GH Actions job ~8 extra `rust-std` downloads). The Apple jobs must use `!reference` rather than `extends` for that, because GitLab's `extends` REPLACES array keys and would silently drop the templates' `SDKROOT`/`STRIP`/`CC`/`CXX` exports. **tvOS: the upstream template's `cargo +nightly build -Zbuild-std` override is OBSOLETE — don't reinstate it.** It dates from when `aarch64-apple-tvos` was tier 3 with no distributed `rust-std`; the target has since been promoted and rustup ships a complete prebuilt std **including `panic_abort`** (verified on the pinned 1.96.0: `rustup target add aarch64-apple-tvos` gives 26 rlibs and the crate `cargo check`s clean, bindgen included). Our job overrides `script` back to `!reference [.libretro-rust-apple-base, script]`, putting tvOS on the same pinned stable as every other job. That one change dissolved THREE stacked workarounds the `+nightly` path had forced: a nightly-channel reinstall (`+nightly` outranks both `rust-toolchain.toml` and `RUSTUP_TOOLCHAIN`, so the job rode the image's stale 1.94.0-nightly, below our MSRV); `CARGO_PROFILE_RELEASE_PANIC=unwind` (bare `-Zbuild-std` omits `panic_abort`, and `CARGO_UNSTABLE_BUILD_STD` does NOT override the hardcoded crate list — the CLI `-Z` flag wins); and clearing the image's `-C ar` (see the next bullet). Worth reporting upstream: every Rust core's tvOS job could drop `+nightly` the same way. **A green GitHub Actions run does not imply a green buildbot** — the new `libretro-cross` CI job (one triple for each buildbot ABI family a Linux runner can model — MinGW-Windows and Android/NDK; the Apple families are deliberately excluded, as bindgen needs a real per-target sysroot and there is no Apple SDK on a Linux runner) is the early-warning gate; before touching anything libretro-related, cross-check `cargo check --release -p rustynes-libretro --target ` locally.
- **The libretro build image injects `-C ar` into EVERY Apple job, and it is a hard error from Rust 1.97 — a bomb armed against the next MSRV bump.** The image (not the `rust-apple.yml` template, which sets no `RUSTFLAGS` at all, and not our `.cargo/config.toml`) adds `-Car=,Clink-arg=-undefined,Clink-arg=dynamic_lookup,-rpath=` to osx-x64 / osx-arm64 / ios-arm64 / tvos-arm64. `-C ar` was a deprecated no-op for years and became a **hard error in 1.97** (bisected locally: 1.93.0-nightly / 1.96.0 / 1.96.1 warn; 1.97.1 and 1.99.0-nightly error). No job trips it today — all four Apple jobs are on the pinned 1.96.0 and merely log the warning. **The day `rust-toolchain.toml` moves to 1.97+, all four fail together** — the warning lives in that file, at the line someone would edit. Discarding the flags is behaviour-preserving, not a gamble: rustc splits `-C` at the FIRST `=`, so the whole comma-joined string is swallowed as the `ar` value and those link args have never reached the linker for *any* core (cargo prints it as one argv token), and two upstream Rust cores have green tvOS jobs on the same image with the same dead token. The override works without knowing where the image sets it because cargo takes rustflags from exactly one source, first match wins: `CARGO_ENCODED_RUSTFLAGS` → `RUSTFLAGS` → `target..rustflags` → `build.rustflags` (verified locally against a global `~/.cargo/config.toml` `build.rustflags`: `RUSTFLAGS=""` removes every injected `-C`, and empty means zero flags, not one empty argument).
-- **There is ONE toolchain, `rust-toolchain.toml`'s `channel`, and no version literal anywhere in `.github/` — don't add one.** `.github/actions/rust-setup` parses the channel out of that file and fails closed if it can't, so a toolchain bump is a one-line edit there. Pass the composite's `toolchain:` input only to install something *deliberately* different from the project pin. **The resolver is table-scoped `awk` on purpose — do NOT "simplify" it back to a one-line `sed`.** Matching the first `channel = "..."` *anywhere* in the file (the first implementation, caught in review on PR #322) resolves `nightly` if any other table carries a `channel` key ahead of `[toolchain]` — silently installing the very toolchain this setup exists to keep out, while the step still reports success. `awk` rather than `tomllib` because the step runs on Windows and macOS runners too and Python ≥3.11 is not a safe assumption there; only double-quoted TOML strings are accepted, and anything else (missing table, single-quoted value, empty file) aborts the job rather than being guessed at. The old `stable` default was misleading rather than wrong: `rust-toolchain.toml` is a directory override that outranks the `rustup default` the action performs, so every job was already compiling on 1.96.0 (rustup logs `overridden by .../rust-toolchain.toml`) — `stable` just downloaded a second toolchain nothing used and made the workflows *read* as though they tested latest stable, which they never did. **Nightly is used in exactly two places, neither a gate:** `cargo fuzz` (hard requirement — libFuzzer's sanitizer flags are nightly-only) and the dormant `rustynes-monetization` crate's standalone `uniffi-bindgen`. If you think a CI job needs nightly, it doesn't.
+- **There is ONE toolchain, `rust-toolchain.toml`'s `channel`, and no version literal anywhere in `.github/` — don't add one.** `.github/actions/rust-setup` parses the channel out of that file and fails closed if it can't, so a toolchain bump is a one-line edit there. Pass the composite's `toolchain:` input only to install something *deliberately* different from the project pin. **The resolver is table-scoped `awk` on purpose — do NOT "simplify" it back to a one-line `sed`.** Matching the first `channel = "..."` *anywhere* in the file (the first implementation, caught in review on PR #322) resolves `nightly` if any other table carries a `channel` key ahead of `[toolchain]` — silently installing the very toolchain this setup exists to keep out, while the step still reports success. `awk` rather than `tomllib` because the step runs on Windows and macOS runners too and Python ≥3.11 is not a safe assumption there; only double-quoted TOML strings are accepted, and anything else (missing table, single-quoted value, empty file) aborts the job rather than being guessed at. The old `stable` default was misleading rather than wrong: `rust-toolchain.toml` is a directory override that outranks the `rustup default` the action performs, so every job was already compiling on 1.96.0 (rustup logs `overridden by .../rust-toolchain.toml`) — `stable` just downloaded a second toolchain nothing used and made the workflows *read* as though they tested latest stable, which they never did. **Nightly is used in exactly one place, not a gate:** `cargo fuzz` (hard requirement — libFuzzer's sanitizer flags are nightly-only). If you think a CI job needs nightly, it doesn't.
- **`rust-libretro 0.3.2` is unmaintained (no commit since 2023-02) and has a MinGW bug we work around.** It casts a keycode with `cfg(target_family = "windows")`, but C enum signedness follows the *ABI*: only **MSVC** gives plain enums `int` — under **MinGW** (`x86_64-pc-windows-gnu`, what the buildbot builds) bindgen emits `c_uint` and the crate fails `E0308`. `.cargo/config.toml`'s `[env] BINDGEN_EXTRA_CLANG_ARGS_x86_64_pc_windows_gnu = "--target=x86_64-pc-windows-msvc"` fixes it; the generated-bindings diff is 28 lines, all enum signedness. Don't "clean up" that env var without rebuilding for `x86_64-pc-windows-gnu`.
- **CodeRabbit is now a 3rd automated PR review bot** (`.coderabbit.yaml`, added 2026-07-20 in PR #316), alongside gemini-code-assist and copilot-pull-request-reviewer — same reply-and-resolve-every-thread ceremony applies before any merge. Configured `profile: assertive` (not the "chill" default) and a `tools{}`/`path_instructions`/custom-checks set audited against this repo's actual file footprint, not guessed. `tone_instructions` has a hard 250-character schema limit that fails validation silently on the CodeRabbit side — after editing `.coderabbit.yaml`, verify with a `@coderabbitai configuration` PR comment and confirm every changed field shows `Source: Repository YAML (base)`.
- **lz4_flex 0.14+ requires the crate's own `alloc` feature explicitly** for `compress_prepend_size`/`decompress_size_prepended` (used by `rewind.rs`/`zwinder.rs`) — it split real no_std support into an `alloc`-vs-`std` distinction that didn't exist in 0.13. A `cargo build --workspace` will NOT catch a missing `alloc` feature here because `rustynes-core`'s own default-on `std` feature implies it via cargo's feature unification; only a standalone `cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features` (the exact CI `no_std build` job) will. Run that command locally before pushing any bump that touches this dependency.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a29037f..2346c0ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,49 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [2.2.6] - 2026-08-04 - "Almanac" (de-monetization + provenance accuracy)
+
+A **de-monetization and provenance** release. RustyNES is now permanently
+open-source and **income/profit-free forever** (ADR 0035): all planned
+monetization is removed and the native Android/iOS apps are kept as **free FOSS
+apps** — no ads, no tracking, no paid unlock, every feature available. **Zero
+emulation-core behavior changes** — the `#![no_std]` chip stack, save-state / TAS
+/ netplay formats, and every golden vector are byte-identical, so **AccuracyCoin
+holds 141/141 (100.00%)** and nestest is 0-diff by construction.
+
+### Removed
+
+- The `rustynes-monetization` crate and `docs/monetization/` are deleted and the
+ workspace member removed (no emulation crate ever depended on it). The Android
+ paid layer (Play Billing `LicenseManager`, the AppLovin MAX / RevenueCat
+ `MonetizationGate` + ad gates, the demo/paywall UI + strings, AdMob/AppLovin
+ manifest entries, and the billing/ad Gradle deps + BuildConfig keys + the
+ monetization cargo/uniffi tasks) is removed; `MainActivity` no longer gates any
+ feature behind an unlock/demo. The iOS paid layer (the StoreKit `StoreManager`,
+ the `appStore` monetization build channel, billing entitlements) is removed.
+
+### Changed
+
+- The `foss` / `play` Android flavor split is retained but now only distinguishes
+ the pure-AOSP build from the build carrying the *free* Google-Play services
+ (Play Games achievements, Cast, Integrity, in-app update, cloud save) — no ads,
+ no billing. Nightly Rust is now used only by `cargo fuzz`.
+- ROADMAP / `docs/STATUS.md` / version plans reframed to the OSS/income-free
+ position; the freed v2.3.0 slot is repurposed for accuracy/fidelity work.
+- **Provenance accuracy (ADR 0035 + ADR 0030):** `NOTICE` and
+ `docs/originality-and-provenance.md` now disclose honestly that the PPU
+ octal-latch / hybrid-address *timing* was calibrated to TriCNES's per-dot
+ behavior (beyond black-box oracle use), which reproduced a TriCNES-specific
+ artifact that mis-renders mid-render `$2006` writes (e.g. Rad Racer). This is
+ scheduled to be reworked to be documentation-derived in v2.3.0.
+
+### Added
+
+- **ADR 0035** "RustyNES is permanently non-commercial (no monetization)";
+ **ADR 0025** marked Superseded and **ADR 0027** amended (its App-Store §4.7
+ ROM-compliance rules stay — valid for a free app; the ad/ATT/StoreKit-unlock
+ clauses are removed).
+
## [2.2.5] - 2026-08-03 - "Colophon" (provenance, licensing, and documentation integrity)
A **provenance, licensing, and documentation-integrity** release, prompted by
diff --git a/Cargo.lock b/Cargo.lock
index f08364f1..54adf122 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"android-activity",
"android_logger",
@@ -4308,7 +4308,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"cc",
"ureq",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"anstyle",
"arboard",
@@ -4411,11 +4411,11 @@ dependencies = [
[[package]]
name = "rustynes-gfx-shaders"
-version = "2.2.5"
+version = "2.2.6"
[[package]]
name = "rustynes-hdpack"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"lewton",
"png",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bytemuck",
"cpal",
@@ -4440,7 +4440,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"libc",
"rust-libretro",
@@ -4449,7 +4449,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4461,7 +4461,7 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"rustynes-core",
"rustynes-hdpack",
@@ -4474,16 +4474,9 @@ dependencies = [
"zip",
]
-[[package]]
-name = "rustynes-monetization"
-version = "2.2.5"
-dependencies = [
- "uniffi",
-]
-
[[package]]
name = "rustynes-netplay"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"futures-util",
"js-sys",
@@ -4499,7 +4492,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4511,14 +4504,14 @@ dependencies = [
[[package]]
name = "rustynes-ra"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"mlua",
"piccolo",
@@ -4529,7 +4522,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "2.2.5"
+version = "2.2.6"
dependencies = [
"insta",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index 6daaefe5..64370a8f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,13 +17,13 @@ members = [
"crates/rustynes-gfx-shaders",
"crates/rustynes-hdpack",
"crates/rustynes-ra",
- "crates/rustynes-monetization", "crates/rustynes-libretro",
+ "crates/rustynes-libretro",
]
# The libretro buildbot's shared CI templates run a bare, unscoped
# `cargo build --release --target ` from the repo root (no `-p`,
# no `--manifest-path` — the script is centrally maintained and can't be
# edited per-core). Scoping the default member to just the libretro crate
-# keeps that command building ONLY `rustynes-libretro`, not all 18 workspace
+# keeps that command building ONLY `rustynes-libretro`, not all 17 workspace
# members (in particular `rustynes-frontend`, which needs wgpu/winit/cpal/
# wayland system libs the buildbot's headless cross-compile image doesn't
# have). This does not change any `--workspace`/`-p `-scoped command
@@ -32,7 +32,7 @@ members = [
default-members = ["crates/rustynes-libretro"]
[workspace.package]
-version = "2.2.5"
+version = "2.2.6"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
diff --git a/NOTICE b/NOTICE
index d93fded2..2798f68c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -66,6 +66,13 @@ permission notice are reproduced below.
oracle at crates/rustynes-test-harness/golden/tricnes/tricnes-full-src/.
https://github.com/100thCoin/TriCNES (commit 9199870)
Copyright (c) 2025 Chris Siebert
+ Note (v2.2.6): the octal-latch / hybrid-address *timing* was calibrated to
+ TriCNES's per-dot behavior rather than derived independently from hardware
+ documentation, which reproduced a TriCNES-specific artifact (mis-rendering
+ mid-render $2006 writes, e.g. Rad Racer). This is disclosed in
+ docs/originality-and-provenance.md sec. 2.2 and is being reworked to be
+ documentation-derived in v2.3.0 (see ADR 0030). TriCNES is MIT, so this is a
+ behavioral-fidelity note, not a licensing one.
* rcheevos v12.3.0 -- RetroAchievements client runtime, vendored at
crates/rustynes-cheevos/vendor/rcheevos/ (compiled only under the optional
diff --git a/README.md b/README.md
index dfbb07f3..0f4fad56 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-

+


@@ -102,8 +102,8 @@ platform for NES emulation.
| **HD Audio** *(v1.6.0)* | HD-pack `` / `` OGG tracks triggered through the `$4100` register, mixed on top of the produced APU buffer (default-off `hd-pack`) |
| **Shader Ecosystem** *(v1.6.0)* | LMP88959 NTSC/PAL, hqNx / xBRZ upscalers, and a constrained `.slangp` / `.cgp` preset import on the composable ShaderStack |
| **Writable + Programmable** *(v1.7.0)* | Editing-capable debug tools (palette / nametable / CHR / OAM writeback, an iNES / NES 2.0 header editor, an inline 6502 assembler), a scriptable `tastudio.*` Lua API, host IPC automation (`script-ipc`), `.dbg` source maps, Zwinder tiered rewind, audio depth (stereo / reverb / 20-band EQ), web parity, and an i18n framework |
-| **Android App** *(v1.8.x)* | A complete native Android app on the byte-identical core — a multi-touch + hardware-controller (P1–P4) UI, wgpu `SurfaceView` rendering, save-states, Lua, RetroAchievements, direct-IP / CGNAT-TURN netplay, and a box-art ROM library (GitHub-sideload now; Google Play at v2.3.0) |
-| **iOS / iPadOS App** *(v1.9.x)* | A complete native iOS app on the byte-identical core — a native SwiftUI shell over Metal (`wgpu`), multi-touch + GameController support, iCloud save-state sync, room-code rollback netplay, RetroAchievements, and the full TAStudio power-user suite (TestFlight now; App Store at v2.3.0) |
+| **Android App** *(v1.8.x)* | A complete native Android app on the byte-identical core — a multi-touch + hardware-controller (P1–P4) UI, wgpu `SurfaceView` rendering, save-states, Lua, RetroAchievements, direct-IP / CGNAT-TURN netplay, and a box-art ROM library (GitHub-sideload now; free Google Play listing possible later) |
+| **iOS / iPadOS App** *(v1.9.x)* | A complete native iOS app on the byte-identical core — a native SwiftUI shell over Metal (`wgpu`), multi-touch + GameController support, iCloud save-state sync, room-code rollback netplay, RetroAchievements, and the full TAStudio power-user suite (TestFlight now; free App Store listing possible later) |
| **Libretro Core** *(v1.10.0)* | A complete, cycle-accurate Libretro core (the `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll` by platform) integrating RustyNES seamlessly into RetroArch with RetroAchievements, dynamic audio sync, and deterministic rollback/save-state support |
| **One-Clock Timebase** *(v2.0.0)* | A single canonical cycle counter with every CPU cycle a real bus access and a split-around-the-access PPU catch-up, replacing the five-counter dot-lockstep scheduler; the release's designated breaking-behavior change (ADR 0002 / ADR 0029) |
| **Vs. DualSystem** *(v2.0.0 core · v2.1.2 desktop)* | Core-level emulation of the two-CPU/two-PPU Vs. arcade cabinet boards (Tennis, Baseball, Wrecking Crew, Balloon Fight) via a shared-WRAM + cross-wired `$4016`/IRQ convergence model — now presented on desktop as a composed **two-screen** view (side-by-side / stacked) with both consoles cross-wired |
@@ -401,10 +401,13 @@ UniFFI bridge, a **`rustynes-android`** JNI layer, and a Jetpack **Compose** she
EN/ES i18n, screenshot / MP4 capture, Picture-in-Picture, widgets, and accessibility
(high-contrast + Okabe-Ito).
-The apps ship now as **GitHub-Releases / sideload**, full-featured; the Google Play
-production launch — with an ad-supported-freemium model and the `foss` / `play` flavor
-split — is **deferred to the v2.3.0 joint store launch** (see [Roadmap](#roadmap)).
-Details in [`docs/android.md`](docs/android.md).
+The apps ship now as **GitHub-Releases / sideload**, full-featured; a possible
+**free** Google Play / F-Droid listing — a free app with the `foss` / `play` flavor
+split distinguishing pure-AOSP builds from optional free Google Play services
+(achievements, Cast, Integrity, in-app update, cloud save) — is a **later** step with
+no fixed version (see [Roadmap](#roadmap)). RustyNES is permanently open-source and
+income-free (ADR 0035): no ads, no tracking, no paid unlock. Details in
+[`docs/android.md`](docs/android.md).
### iOS / iPadOS *(v1.9.x)*
@@ -413,9 +416,9 @@ RustyNES runs as a native **iOS / iPadOS app** on the byte-identical core (maint
- **Rendering + audio** — Metal via `wgpu` with the same full WGSL shader pipelines (CRT, NTSC, Bisqwit) and ProMotion pacing, plus a low-latency CoreAudio hot path.
- **Input** — multi-touch on-screen pad (NES-001 style), responsive sizing, GameController framework for P1–P4 (hot-plug), and Core Haptics.
- **Connectivity & Tooling** — room-code netplay (CGNAT/TURN) and LAN rollback, RetroAchievements, iCloud save-state sync (CloudKit), Lua console, and power-user tooling (TAS `.rnm` movies, `.pal` palettes, `.zip` ROMs, HD-pack loading).
-- **Platform polish** — ReplayKit capture, Game Center, accessibility, EN/ES i18n, 4-slot save-state manager, and the dormant StoreKit `foss`/App-Store seam.
+- **Platform polish** — ReplayKit capture, Game Center, accessibility, EN/ES i18n, and a 4-slot save-state manager. (No monetization — the app is free; see [ADR 0035](docs/adr/0035-rustynes-is-permanently-non-commercial.md).)
-The apps are currently distributed via **TestFlight**; the App Store launch is deferred to the **v2.3.0** joint store launch (see [Roadmap](#roadmap)). Details in [`docs/ios.md`](docs/ios.md).
+The apps are currently distributed via **TestFlight**; a future **free** App Store listing (no ads, no purchase) is possible but has no fixed version. Details in [`docs/ios.md`](docs/ios.md).
---
@@ -620,15 +623,14 @@ in [`docs/architecture.md`](docs/architecture.md) and [`docs/scheduler.md`](docs
| `rustynes-hdpack` | HD-pack loader + compositor + HD audio (shared desktop + mobile) |
| `rustynes-mobile` | UniFFI bridge for the mobile platforms (Android, and v1.9.0 iOS) |
| `rustynes-android` | Android JNI glue over the mobile bridge |
-| `rustynes-monetization` | `AdPolicy` ad-supported-freemium policy core (v2.3.0; dormant) |
| `rustynes-test-harness` | Integration tests and the accuracy / commercial-ROM oracles |
### Project layout
```text
crates/ Cargo workspace: the crates above
-docs/ Implementation specs, ADRs, the user guide, the monetization
- design set, STATUS.md (single source of truth), and release notes
+docs/ Implementation specs, ADRs, the user guide,
+ STATUS.md (single source of truth), and release notes
deploy/ Docker / compose for the browser-netplay signaling server + STUN/TURN
ref-docs/ Deep-research NES hardware reference
tests/ Integration tests + vendored CC0 / MIT / zlib test ROMs (no commercial ROMs)
@@ -736,7 +738,7 @@ The reproducible record (methodology, all benches, and the historical A/B) is in
| **Android (arm64)** | Supported (v1.8.x; GitHub-Releases / sideload — see [`docs/android.md`](docs/android.md)) |
| **Linux ARM64** | Supported (cross-compile) |
| **Libretro Core** | Supported (RetroArch via `rustynes-libretro`) |
-| **iOS / iPadOS** | Supported (v1.9.x TestFlight; App Store at v2.3.0) |
+| **iOS / iPadOS** | Supported (v1.9.x TestFlight; free App Store listing possible later) |
### System requirements
@@ -793,12 +795,29 @@ and the Material-for-MkDocs documentation handbook at
## Current Release
-RustyNES's current release is **v2.2.5 "Colophon"**, a **provenance, licensing,
-and documentation-integrity** release. It carries **zero emulation-core
-behavior changes** — so **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and
-the `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden
-vector are byte-identical to v2.2.4 by construction. Instead it corrects how the
-project *describes its own provenance*, prompted by community review:
+RustyNES's current release is **v2.2.6 "Almanac"**, a **de-monetization and
+provenance** release that opens the **v2.2.6 → v2.3.0** line addressing NESdev-forum
+feedback. It carries **zero emulation-core behavior changes** — so **AccuracyCoin holds
+141/141 (100.00%)**, nestest is 0-diff, and the `#![no_std]` chip stack, save-state / TAS
+/ netplay formats, and every golden vector are byte-identical by construction. What it
+does:
+
+- **RustyNES is now permanently open-source and income-free (ADR 0035).** All planned
+ monetization is removed — the `rustynes-monetization` crate, the `docs/monetization/`
+ design set, and the Android/iOS billing, ad, freemium/demo, and paywall layers. The
+ native apps are **kept as free FOSS apps**: no ads, no tracking, no paid unlock, every
+ feature available. The free Google-Play *services* (Play Games achievements, Cast,
+ Integrity, in-app update, cloud save) and the `foss`/`play` split are retained.
+- **Provenance accuracy.** `NOTICE` and
+ [`docs/originality-and-provenance.md`](docs/originality-and-provenance.md) now disclose
+ honestly that the PPU octal-latch / hybrid-address *timing* was calibrated to TriCNES's
+ per-dot behavior (beyond black-box oracle use), which reproduces a TriCNES-specific
+ artifact that mis-renders mid-render `$2006` writes (e.g. Rad Racer) — flagged for a
+ documentation-derived rework in v2.3.0 (ADR 0030).
+
+The prior release, **v2.2.5 "Colophon"** (a **provenance, licensing, and
+documentation-integrity** release, also zero-core-change), corrected how the project
+*describes its own provenance*, prompted by community review:
- A full-tree audit reworded in-source comments that had mischaracterized
implementations of publicly-documented hardware behavior as "ports of" copyleft
@@ -903,10 +922,14 @@ launch:
performance, features, and quality (the v2.1.5 "Regression Net & Residual" work — a
Holy Mapperel mapper bank-reachability + IRQ regression net wired into CI — is already
under way in `[Unreleased]`).
-- **v2.3.0** — the **joint mobile store launch** (Google Play + Apple App Store + F-Droid +
- AltStore PAL), turning on the `foss` / `play` flavor split (ADR 0025) and the
- ad-supported-freemium monetization (AppLovin MAX + RevenueCat, a one-time **$3.99**
- unlock).
+- **v2.2.6 → v2.3.0** — the **de-monetization + NESdev-remediation** line. v2.2.6
+ "Almanac" removes all monetization (RustyNES is permanently open-source and
+ income-free, ADR 0035; the apps stay free FOSS — no ads, no tracking, no paid
+ unlock), and v2.2.7 → v2.3.0 address the NESdev-forum feedback (audio aliasing /
+ VRC6 / Sunsoft 5B; gamma-aware resampling + scanlines; TAStudio + `.bk2` + floating
+ windows; and the PPU left-edge + hybrid-address accuracy capstone). A **free** mobile
+ store listing (Google Play / F-Droid / App Store) is a possible **later**, unversioned
+ step with no monetization attached.
The exact per-release scope beyond v2.1.4 is planning, not a shipped promise — see the
roadmap for the current framing.
@@ -1014,7 +1037,7 @@ If you use RustyNES in academic research, please cite:
author = {RustyNES Contributors},
title = {RustyNES: A Cycle-Accurate NES Emulator in Rust},
year = {2026},
- version = {2.2.5},
+ version = {2.2.6},
url = {https://github.com/doublegate/RustyNES},
note = {Cycle-accurate NES emulator on a master-clock-precise scheduler;
AccuracyCoin 100\% (141/141), nestest 0-diff; 172 mapper families,
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 059e7dc2..5a5aba94 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -49,10 +49,11 @@ android {
versionName = "2.0.4"
// No abiFilters here — set per buildType so release ships arm64 only
// while debug keeps x86_64 for the emulator.
- // PLAY_BUILD moved to the `distribution` product flavors below (v2.0.1, ADR
- // 0025): it is now `false` for `foss` and `true` for `play`, so the flag is
- // a compile-time property of the channel rather than a defaultConfig default.
- // It still gates the freemium (demo timer + persistence locks + Billing).
+ // PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`),
+ // so the channel is a compile-time property. RustyNES is permanently
+ // open-source and income-free, so this flag NO LONGER gates any monetization —
+ // it only distinguishes the Play-services channel for the non-billing helpers
+ // (e.g. PlayUpdates skips its in-app update/review work on the `foss` channel).
// CHROMECAST_ENABLED gates the experimental Cast Application Framework
// (CAF) sender path (v1.8.7, #38) — a ~20-30fps SPECTATOR mirror to a
// custom Web Receiver, distinct from the primary low-latency Presentation
@@ -70,12 +71,11 @@ android {
// Play Console and flips this on. DISTINCT from RetroAchievements (rustynes-ra,
// v1.8.6) which stays untouched. See CloudSave.kt + PlayGames.kt.
buildConfigField("boolean", "PGS_ENABLED", "false")
- // v1.8.8 "Atlas" (Workstream L): Play Integrity anti-tamper layer OVER Billing
- // (Billing remains the entitlement source of truth). Default false: no token
- // request, no verdict handling, zero behavior change. The verdict DECRYPTION
- // requires a linked Google Cloud project + a server endpoint (maintainer ops),
- // so the on-device handler is a documented stub until that lands. A failed/
- // absent verdict must NEVER revoke a legitimate purchase. See Integrity.kt.
+ // v1.8.8 "Atlas" (Workstream L): Play Integrity anti-tamper layer. Default
+ // false: no token request, no verdict handling, zero behavior change. The
+ // verdict DECRYPTION requires a linked Google Cloud project + a server endpoint
+ // (maintainer ops), so the on-device handler is a documented stub until that
+ // lands. See Integrity.kt.
buildConfigField("boolean", "PLAY_INTEGRITY_ENABLED", "false")
// v1.8.8 "Atlas" (Workstream L): the maintainer's linked Google Cloud project
// NUMBER for Play Integrity's PrepareIntegrityTokenRequest. 0L = unset (the
@@ -84,20 +84,17 @@ android {
}
// v2.0.1 (ADR 0025): the `distribution` product-flavor split. Two channels off
- // the one byte-identical Timebase core:
- // - `foss` (default) — the pure-Rust emulator ONLY: no Google Play SDKs, no
- // ads, no tracking. This is the F-Droid + GitHub-Releases sideload artifact.
- // Its `src/foss/` source set supplies no-op façades for every proprietary
- // subsystem so `MainActivity` links without any `com.google.*` /
- // `com.android.billingclient.*` dependency (moved to `playImplementation`).
- // - `play` — everything proprietary (Billing, Cast framework, Play Games v2,
- // Play Integrity, in-app update/review; AppLovin/RevenueCat ads land here at
- // v2.1.0). Google Play only.
+ // the one byte-identical Timebase core. RustyNES is permanently open-source and
+ // income-free — there is NO billing, NO ads, and NO tracking in either channel;
+ // the split now distinguishes only the non-monetization Google Play services.
+ // - `foss` (default) — the pure-Rust emulator ONLY: no Google Play SDKs. This is
+ // the F-Droid + GitHub-Releases sideload artifact. Its `src/foss/` source set
+ // supplies no-op façades for every proprietary subsystem so `MainActivity`
+ // links without any `com.google.*` dependency (moved to `playImplementation`).
+ // - `play` — the optional Google Play services (Cast framework, Play Games v2,
+ // Play Integrity, in-app update/review, cloud save), all free. Google Play only.
// PLAY_BUILD is set per-flavor; the per-feature runtime gates (PGS_ENABLED, …)
// stay in defaultConfig as the in-`play` toggles the maintainer flips at launch.
- // NOTE: this is the STRUCTURAL start of the split (v2.0.1). The monetization ad
- // glue is still dormant (ADR 0025); only the existing proprietary SDK groups are
- // flavor-scoped here.
flavorDimensions += "distribution"
productFlavors {
create("foss") {
@@ -108,29 +105,6 @@ android {
create("play") {
dimension = "distribution"
buildConfigField("boolean", "PLAY_BUILD", "true")
- // v2.0.3 (ADR 0025): the monetization SDK identifiers, PLAY-FLAVOR ONLY so the
- // `foss` artifact carries no ad-unit / SDK keys at all. Sourced from gradle
- // properties (keep real keys out of source control — inject via
- // `~/.gradle/gradle.properties` or CI secrets); each defaults to "" so a keyless
- // build still compiles + links (the AppLovin / RevenueCat SDKs simply no-op /
- // fail-soft with an empty key, which is correct for a dormant build). Consumed by
- // MonetizationGate / AdGate / RewardedGate / RcBilling via BuildConfig.*.
- buildConfigField(
- "String", "APPLOVIN_SDK_KEY",
- "\"${providers.gradleProperty("applovinSdkKey").orNull ?: ""}\"",
- )
- buildConfigField(
- "String", "REVENUECAT_API_KEY",
- "\"${providers.gradleProperty("revenueCatGoogleKey").orNull ?: ""}\"",
- )
- buildConfigField(
- "String", "MAX_INTERSTITIAL_AD_UNIT_ID",
- "\"${providers.gradleProperty("maxInterstitialAdUnitId").orNull ?: ""}\"",
- )
- buildConfigField(
- "String", "MAX_REWARDED_AD_UNIT_ID",
- "\"${providers.gradleProperty("maxRewardedAdUnitId").orNull ?: ""}\"",
- )
}
}
@@ -194,20 +168,11 @@ android {
} else {
signingConfigs.getByName("debug")
}
- // v2.0.3 (ADR 0025): the RevenueCat local-QA tester unlock is compiled OUT of
- // every release build (including the closed-test track, which is a release
- // build) — RcBilling.testerUnlockEnabled() is `DEBUG && TESTER_UNLOCK`, so this
- // false makes it a constant `false` and no unlock can leak to a store build.
- buildConfigField("boolean", "TESTER_UNLOCK", "false")
}
debug {
applicationIdSuffix = ".debug"
// Debug keeps x86_64 too so it installs on the emulator / CI.
ndk { abiFilters += builtAbis }
- // v2.0.3 (ADR 0025): allow the debug-only RevenueCat tester unlock (still
- // additionally gated on BuildConfig.DEBUG inside RcBilling). Play-flavor only in
- // effect; the foss build never reads it.
- buildConfigField("boolean", "TESTER_UNLOCK", "true")
}
}
@@ -299,10 +264,6 @@ val cargoNdkBuild by tasks.registering(Exec::class) {
"build", "--release",
"-p", "rustynes-mobile",
"-p", "rustynes-android",
- // v1.8.9 build-out: the monetization AdPolicy core (our own clean Rust —
- // NO Google SDKs, NO ads). Wired in dormant; nothing calls it yet (the
- // AppLovin/RevenueCat glue is the v2.1.0 `play`-flavor step, ADR 0025).
- "-p", "rustynes-monetization",
),
)
}
@@ -322,25 +283,7 @@ val uniffiBindgen by tasks.registering(Exec::class) {
)
}
-// Generate the Kotlin bindings for the monetization AdPolicy core (a second, separate
-// UniFFI crate → its own `com.doublegate.rustynes.monetization.ffi` package, written into
-// the same generated dir already on the main source set). Its bindgen bin needs the crate's
-// `cli` feature (unlike rustynes-mobile, which enables uniffi/cli unconditionally).
-val uniffiBindgenMonetization by tasks.registering(Exec::class) {
- group = "rust"
- description = "Generate Kotlin bindings for the rustynes-monetization AdPolicy core via UniFFI."
- dependsOn(cargoNdkBuild)
- workingDir = workspaceRoot
- val lib = workspaceRoot.resolve("target/aarch64-linux-android/release/librustynes_monetization.so")
- commandLine(
- "cargo", "run", "-q", "-p", "rustynes-monetization", "--features", "cli",
- "--bin", "uniffi-bindgen", "--",
- "generate", "--library", lib.absolutePath,
- "--language", "kotlin", "--out-dir", uniffiGenDir.absolutePath,
- )
-}
-
-tasks.named("preBuild") { dependsOn(uniffiBindgen, uniffiBindgenMonetization) }
+tasks.named("preBuild") { dependsOn(uniffiBindgen) }
dependencies {
// v1.8.8 "Atlas": Compose BOM 2025.09.01 (material3 1.4.0 — the stable M3 set
@@ -404,15 +347,11 @@ dependencies {
// UniFFI's generated Kotlin loads the cdylib through JNA; the `@aar`
// classifier pulls the Android-native JNA dispatcher.
implementation("net.java.dev.jna:jna:5.18.1@aar")
- // Play Billing — the one-time "Full Unlock" IAP (Workstream M, freemium model).
- // Pinned at 8.0.0 here: Billing 9.x is an API-breaking major (the v1.8.8 Play
- // launch / Workstream P revisits the entitlement code), and this Atlas-foundation
- // pass is presentation/Gradle only — bumping it would touch Billing.kt/LicenseManager.
- // v2.0.1 (ADR 0025): PLAY-FLAVOR ONLY. `playImplementation` keeps these
- // proprietary Google-Play SDKs out of the `foss` (F-Droid/sideload) artifact
- // entirely — the `foss` variant links none of them (its `src/foss/` no-op
- // façades stand in), so the clean channel has no Play Services / Billing / ads.
- "playImplementation"("com.android.billingclient:billing-ktx:8.0.0")
+ // v2.0.1 (ADR 0025): the optional Google Play services below are PLAY-FLAVOR ONLY.
+ // `playImplementation` keeps these proprietary Google-Play SDKs out of the `foss`
+ // (F-Droid/sideload) artifact entirely — the `foss` variant links none of them (its
+ // `src/foss/` no-op façades stand in), so the clean channel has no Play Services.
+ // RustyNES carries no billing and no ads in either channel.
// Cast Application Framework sender (v1.8.7, #38). Linked but DORMANT: it does
// nothing until CastContext is initialized, which only happens behind the
// default-off BuildConfig.CHROMECAST_ENABLED flag (see ChromecastSender.kt).
@@ -426,10 +365,10 @@ dependencies {
// also reads the manifest , which is a maintainer-supplied
// placeholder (@string/game_services_project_id) until the Play Games project lands.
"playImplementation"("com.google.android.gms:play-services-games-v2:21.0.0")
- // v1.8.8 "Atlas" (Workstream L): Play Integrity API — the anti-tamper layer over
- // Billing. 1.6.0 (SafetyNet Attestation was turned down Jan 2025; this is the modern
- // replacement). Linked but DORMANT: no token is requested until behind the default-
- // off BuildConfig.PLAY_INTEGRITY_ENABLED flag (see Integrity.kt). Verdict decryption
+ // v1.8.8 "Atlas" (Workstream L): Play Integrity API — the anti-tamper layer. 1.6.0
+ // (SafetyNet Attestation was turned down Jan 2025; this is the modern replacement).
+ // Linked but DORMANT: no token is requested until behind the default-off
+ // BuildConfig.PLAY_INTEGRITY_ENABLED flag (see Integrity.kt). Verdict decryption
// needs the maintainer's linked Cloud project + server endpoint.
"playImplementation"("com.google.android.play:integrity:1.6.0")
// v1.8.8 "Atlas" (Workstream L): in-app updates (flexible) + in-app review. 2.1.0
@@ -438,19 +377,6 @@ dependencies {
// install, so they are safe to call unconditionally (still flavor-gated for clarity).
"playImplementation"("com.google.android.play:app-update-ktx:2.1.0")
"playImplementation"("com.google.android.play:review-ktx:2.0.2")
- // v2.0.3 "Harbor" (ADR 0025, Workstream step 5): the monetization SDKs — PLAY-FLAVOR
- // ONLY, so the `foss` (F-Droid / GitHub-Releases) artifact links neither the ad
- // mediation SDK nor the store SDK and stays clean / ad-free / tracking-free.
- // - AppLovin MAX 13 — the interstitial + rewarded mediation used by AdGate /
- // RewardedGate. Hosted on AppLovin's own Maven repo (added in settings.gradle.kts).
- // - RevenueCat 8 — the "premium / remove-ads" entitlement source of truth (RcBilling),
- // pushed into the shared Rust AdPolicy core. On Maven Central.
- // Both are DORMANT behaviourally in the shipped default (no live keys, no ad units, and
- // MonetizationGate's paywall only ever draws once a free budget is exhausted, which the
- // core never reports without a configured session). The glue is structural (ADR 0025):
- // the maintainer flips it live at v2.1.0 with real keys + Play Console products.
- "playImplementation"("com.applovin:applovin-sdk:13.0.1")
- "playImplementation"("com.revenuecat.purchases:purchases:8.10.0")
// v1.8.8 "Atlas" (Workstream J): pull the generated Baseline + Startup Profiles
// from the Macrobenchmark module. The baselineprofile plugin wires the produced
// `baseline-prof.txt` / `startup-prof.txt` into this variant's merged assets.
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 616e1f79..60633bbd 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -60,7 +60,7 @@
# sign-in/achievements/leaderboards/Snapshots, the Integrity StandardIntegrity*
# builders, the AppUpdate/Review managers) so a strict pass can't strip a builder or
# a Task callback type. These are all DORMANT in the default build (PGS_ENABLED /
-# PLAY_INTEGRITY_ENABLED / PLAY_BUILD are false), but R8 has no way to know that.
+# PLAY_INTEGRITY_ENABLED are false), but R8 has no way to know that.
-keep class com.google.android.gms.games.** { *; }
-keep interface com.google.android.gms.games.** { *; }
-keep class com.google.android.play.core.integrity.** { *; }
@@ -75,48 +75,17 @@
# pass can't strip the on-device profile installation path.
-keep class androidx.profileinstaller.** { *; }
-# --- Monetization UniFFI bindings (rustynes-monetization AdPolicy core) --------
-# v2.0.3 "Harbor" (ADR 0025). The SECOND UniFFI crate's Kotlin bindings land in
-# `com.doublegate.rustynes.monetization.ffi.*` (its own package via the crate's
-# uniffi.toml, distinct from `uniffi.rustynes_mobile.*` above). Like the mobile
-# bindings they are reached through JNA reflection, so keep the package WITH
-# constructors + members — R8 strict-full-mode strips members otherwise and the
-# minified `assemblePlayRelease` crashes at the first FFI call (AdPolicy / PlayProgress
-# / the enums). Present only in the `play` variant; harmless (no-op keep) in `foss`.
--keep class com.doublegate.rustynes.monetization.ffi.** { (...); *; }
--keep interface com.doublegate.rustynes.monetization.ffi.** { *; }
-
-# --- AppLovin MAX (play-flavor ad mediation) ----------------------------------
-# v2.0.3 "Harbor" (ADR 0025). AppLovin ships its own consumer rules in the AAR, but
-# under AGP 9.x strict-full-mode pin the SDK + every mediation adapter (loaded by
-# name/reflection from the MAX waterfall) WITH members so R8 can't rename/strip an
-# adapter entry point or an ad listener the SDK calls back reflectively. `-dontwarn`
-# covers the optional networks not on the classpath. `play`-only classes; the keeps
-# are no-ops in the `foss` release (nothing under com.applovin is linked there).
--keep class com.applovin.** { *; }
--keep public class com.applovin.mediation.adapters.** { *; }
--keep public class * extends com.applovin.mediation.adapter.MaxAdapter { *; }
--dontwarn com.applovin.**
-
-# --- RevenueCat (play-flavor entitlement source of truth) ---------------------
-# v2.0.3 "Harbor" (ADR 0025). RevenueCat's Purchases SDK (RcBilling) uses reflection /
-# serialization (kotlinx-serialization models, the BillingClient bridge, the
-# UpdatedCustomerInfoListener). It ships consumer rules, but pin the public surface so a
-# strict pass can't strip a model or the entitlement listener types RcBilling chains.
--keep class com.revenuecat.purchases.** { *; }
--dontwarn com.revenuecat.purchases.**
-
# --- v2.0.4 "Harbor" RC final hardening review (Android release candidate) ------
# The R8 full-mode keep set was re-audited for the release candidate. Every runtime
# reflection / native-boundary surface the minified `assemble{Foss,Play}Release`
# exercises is covered by the rules above:
-# - UniFFI FFI (rustynes_mobile + monetization) + the JNA dispatcher — kept WITH
-# members + (strict-full-mode requirement).
+# - UniFFI FFI (rustynes_mobile) + the JNA dispatcher — kept WITH members +
+# (strict-full-mode requirement).
# - the rustynes-android JNI seam (NativeRenderer `native` methods) — kept by member
# name so RegisterNatives/by-signature resolution survives.
-# - the play-flavor proprietary SDKs (Billing, Cast OptionsProvider, Play Games v2,
-# Play Integrity, app-update/review, AppLovin MAX mediation adapters, RevenueCat) —
-# all kept; harmless no-ops in the `foss` release, which links none of them.
+# - the play-flavor Google Play services (Cast OptionsProvider, Play Games v2,
+# Play Integrity, app-update/review) — all kept; harmless no-ops in the `foss`
+# release, which links none of them.
# - ProfileInstaller's manifest ContentProvider/receiver.
# The v2.0.4 host-side additions carry NO new keeps by construction: `DebugStrictMode`
# is stripped from release entirely (guarded on BuildConfig.DEBUG), the opt-in
diff --git a/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt b/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt
deleted file mode 100644
index 675e2981..00000000
--- a/android/app/src/foss/java/com/doublegate/rustynes/Billing.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-// FOSS-FLAVOR SOURCE SET (v2.0.1, ADR 0025). No-op stand-in for the Play-Billing
-// `LicenseManager` (the real one is in `src/play/.../Billing.kt`). This file links NO
-// `com.android.billingclient.*` — that is the whole point of the FOSS / F-Droid split.
-//
-// The FOSS build has NO freemium / demo gate: there is nothing to purchase, so the app
-// is simply full-featured. [isUnlocked] is therefore a constant `true`, every entitlement
-// call is a no-op, and [priceLabel] is a never-shown placeholder (the unlock affordance
-// is only drawn when `!unlocked`, which never happens here, and behind `PLAY_BUILD`
-// elsewhere). The public surface is byte-for-byte what `MainActivity` calls on the `play`
-// twin, so the shared `MainActivity` (src/main) compiles against either flavor unchanged.
-package com.doublegate.rustynes
-
-import android.app.Activity
-import android.content.Context
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-
-/** The one-time "Full Unlock" product id — unused in FOSS (no Billing), kept for parity. */
-const val FULL_UNLOCK_PRODUCT = "full_unlock"
-
-/**
- * No-op FOSS entitlement manager. Always "unlocked" (the FOSS build ships every feature
- * for free), so the shell's demo gate is inert.
- */
-@Suppress("UNUSED_PARAMETER")
-class LicenseManager(private val appContext: Context) {
-
- /** FOSS is always fully unlocked (no freemium). State-backed to mirror the `play`
- * twin's Compose-observable surface, though it never changes here. */
- var isUnlocked by mutableStateOf(true)
- private set
-
- /** Placeholder price label; never shown in FOSS (the unlock button is gated on
- * `!isUnlocked`, which is never true). Mirrors the `play` twin's `String` surface. */
- val priceLabel: String
- get() = "$2.99"
-
- /** No Play connection in FOSS. */
- fun connect() {}
-
- /** No entitlement to refresh in FOSS. */
- fun refreshEntitlement() {}
-
- /** No purchase flow in FOSS. */
- fun purchase(activity: Activity) {}
-
- /** Debug unlock toggle is meaningless when already permanently unlocked; no-op. */
- fun debugForceUnlocked(value: Boolean) {}
-}
diff --git a/android/app/src/foss/java/com/doublegate/rustynes/MonetizationGate.kt b/android/app/src/foss/java/com/doublegate/rustynes/MonetizationGate.kt
deleted file mode 100644
index b1830e86..00000000
--- a/android/app/src/foss/java/com/doublegate/rustynes/MonetizationGate.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-// FOSS-FLAVOR SOURCE SET (v2.0.3, ADR 0025). No-op stand-in for the freemium /
-// ad-supported monetization façade (the real one is in
-// `src/play/.../MonetizationGate.kt`). This file links NO ads SDK
-// (`com.applovin.*`), NO store SDK (`com.revenuecat.*`), and never even constructs the
-// `AdPolicy` Rust core — that is the whole point of the FOSS / F-Droid split: the clean
-// channel ships every feature for free, ad-free, with zero tracking.
-//
-// Its public surface is byte-for-byte what `MainActivity` (a `src/main` file) calls on
-// the `play` twin, so the shared `MainActivity` compiles against either flavor
-// unchanged. Because every method here is a no-op (or a constant "yes"), the FOSS build
-// is behaviourally identical to the pre-monetization build: no session gate, no run-out
-// timer, no paywall, no ads. This is the byte-identical-default guarantee ADR 0025
-// requires for the F-Droid / GitHub-Releases artifact.
-package com.doublegate.rustynes
-
-import android.app.Activity
-import androidx.compose.runtime.Composable
-import com.doublegate.rustynes.monetization.ffi.PremiumFeature
-
-/**
- * No-op FOSS monetization gate.
- *
- * The FOSS build has no freemium, no ads, and no play-time limit, so every query
- * answers in the user's favour and every side-effecting call is inert. The
- * [PremiumFeature] enum (a pure-Kotlin UniFFI type, no Google dependency) is referenced
- * only to keep the method signature identical to the `play` twin; no FFI method is ever
- * invoked here, so the monetization native library / JNA dispatcher is never loaded in
- * the FOSS process.
- */
-@Suppress("UNUSED_PARAMETER")
-class MonetizationGate(appContext: android.content.Context) {
-
- /** No app-session bookkeeping in FOSS. */
- fun beginSession() {}
-
- /** No ad preloading / SDK init in FOSS. */
- fun onActivityCreated(activity: Activity) {}
-
- /** No entitlement binding / ad refresh in FOSS. */
- fun onResume(activity: Activity) {}
-
- /** Detach any held Activity reference (nothing is held in FOSS). */
- fun onDestroy() {}
-
- /**
- * FOSS ships every feature unlocked, so every gate is open. Mirrors the `play`
- * twin's `featureEnabled(...)` surface; the argument is ignored.
- */
- fun featureEnabled(feature: PremiumFeature): Boolean = true
-
- /** No per-game play budget in FOSS. */
- fun startPlay() {}
-
- /** No play-time accounting in FOSS. */
- fun addActiveTime(deltaMs: Long) {}
-
- /** Play is always allowed in FOSS (no time gate). */
- fun isPlayAllowed(): Boolean = true
-
- /** No progress to persist in FOSS (there is no budget to carry across launches). */
- fun exportProgress(romKey: String) {}
-
- /** No progress to restore in FOSS. */
- fun restoreProgress(romKey: String) {}
-
- /**
- * The run-out paywall + countdown overlay. Draws NOTHING in FOSS (there is no
- * demo/paywall), so the shared `MainActivity` can place it unconditionally in its
- * Compose tree with no visual or behavioural change in the clean build.
- */
- @Composable
- fun RunOutOverlay(onResume: () -> Unit) {
- // Intentionally empty: no paywall in the ad-free FOSS channel.
- }
-}
diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
index 48959fc3..3a6a2db8 100644
--- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
+++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
@@ -17,7 +17,6 @@ import java.security.MessageDigest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -126,15 +125,6 @@ class MainActivity : AppCompatActivity() {
* (needs the application Context) and registered/unregistered in onResume/onPause. */
private lateinit var gamepad: GamepadManager
- /** Freemium entitlement (Workstream M); created in onCreate. */
- private lateinit var license: LicenseManager
-
- /** v2.0.3 "Harbor" (ADR 0025): the ad-supported / freemium monetization façade. Real in
- * the `play` flavor (AppLovin MAX + RevenueCat over the shared Rust AdPolicy core), a
- * no-op twin in `foss` (every feature free, no ads, no gate) — so this construction and
- * every call below is behaviourally inert in the byte-identical FOSS build. */
- private lateinit var monetization: MonetizationGate
-
/** Play Games Services v2 (Workstreams D+E): sign-in + achievements + leaderboards.
* Created in onCreate; all calls no-op behind the default-off PGS_ENABLED flag.
* DISTINCT from RetroAchievements (rustynes-ra, v1.8.6). */
@@ -145,7 +135,7 @@ class MainActivity : AppCompatActivity() {
lateinit var cloudSave: CloudSaveManager
private set
- /** Play Integrity anti-tamper client over Billing (Workstream L); no-op behind the
+ /** Play Integrity anti-tamper client (Workstream L); no-op behind the
* default-off PLAY_INTEGRITY_ENABLED flag + a real cloud project number. */
private lateinit var integrity: IntegrityManager
@@ -207,25 +197,11 @@ class MainActivity : AppCompatActivity() {
// backup/restore or a prefs edit stays authoritative. System (empty tag) clears
// any override and follows the device / per-app system language.
applyPersistedLocale()
- license = LicenseManager(applicationContext)
- // v1.8.8 "Atlas" (Workstream J): the Play Billing `startConnection()` is
- // DEFERRED off the cold-start path to the first foreground (onResume) — it
- // does network/IPC and is not needed to draw the first frame (BillingClient is
- // designed to init lazily). The local entitlement cache is read synchronously
- // in the LicenseManager ctor, so the demo gate is already correct before connect.
gamepad = GamepadManager(applicationContext, emulator)
- // v2.0.3 "Harbor" (ADR 0025): construct the monetization façade and open an app
- // session (drives the core's first-session budget / interstitial suppression). The
- // SDK init (AppLovin / RevenueCat) runs once, guarded, inside the first
- // onActivityCreated call below — so it is on the launch path here; posting it off the
- // first-frame critical path is a tracked v2.0.9 refinement. All inert in FOSS.
- monetization = MonetizationGate(applicationContext)
- monetization.beginSession()
- monetization.onActivityCreated(this)
registerThermalBackoff()
// v1.8.8 "Atlas" (Workstreams D+E+L): Play services managers. All are cheap
- // no-op shells when their gates (PGS_ENABLED / PLAY_INTEGRITY_ENABLED /
- // PLAY_BUILD) are off — the default build constructs them but they do nothing.
+ // no-op shells when their gates (PGS_ENABLED / PLAY_INTEGRITY_ENABLED) are
+ // off — the default build constructs them but they do nothing.
playGames = PlayGamesManager(applicationContext)
cloudSave = CloudSaveManager(applicationContext, playGames)
integrity = IntegrityManager(applicationContext)
@@ -268,14 +244,8 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
- // v2.0.3 "Harbor" (ADR 0025): layer the monetization run-out paywall +
- // countdown over the emulator. `RunOutOverlay` draws NOTHING in the FOSS
- // twin (no paywall, no ads), so this Box is visually/behaviourally inert
- // in the byte-identical FOSS build; in `play` it surfaces the countdown
- // and, at run-out, the rewarded-ad / Full-Version / offline-grace modal.
Box(modifier = Modifier.fillMaxSize()) {
- EmulatorScreen(emulator, gamepad, license, settings)
- monetization.RunOutOverlay(onResume = { emulator.paused = false })
+ EmulatorScreen(emulator, gamepad, settings)
}
}
}
@@ -284,27 +254,10 @@ class MainActivity : AppCompatActivity() {
}
}
- /** Guards the one-time deferred Billing connect (v1.8.8 WS J cold-start deferral). */
- private var billingConnected = false
-
override fun onResume() {
super.onResume()
- // v1.8.8 "Atlas" (Workstream J): connect to Play Billing on the FIRST foreground
- // (kept off onCreate / the cold-start path). Subsequent resumes just re-verify.
- if (BuildConfig.PLAY_BUILD && ::license.isInitialized) {
- if (!billingConnected) {
- license.connect()
- billingConnected = true
- }
- // Re-verify entitlement against Play on each foreground (a purchase made
- // elsewhere, a refund, or a restore reflects here).
- license.refreshEntitlement()
- }
// Start listening for controller hot-plug + enumerate connected pads.
if (::gamepad.isInitialized) gamepad.register()
- // v2.0.3 "Harbor" (ADR 0025): re-verify the monetization entitlement on foreground
- // (a purchase/refund made elsewhere reflects here). No-op in the FOSS twin.
- if (::monetization.isInitialized) monetization.onResume(this)
// v1.8.8 "Atlas" (Workstream L): Play-services foreground work, all off the
// cold-start path (first/each resume). Each no-ops on sideload / behind its flag.
if (::playUpdates.isInitialized && !updateChecked) {
@@ -314,7 +267,7 @@ class MainActivity : AppCompatActivity() {
}
if (::playUpdates.isInitialized) playUpdates.resumeStalledUpdate()
// Warm the Play Integrity Standard token provider (no-op without the flag + a
- // real cloud project number). Defense-in-depth over Billing; never blocks.
+ // real cloud project number). Advisory-only health signal; never blocks.
if (::integrity.isInitialized) integrity.prepareToken()
// Confirm PGS sign-in state (PGS v2 auto-signs-in; refresh the flag silently).
// The PGS v2 client factories need an Activity — bind this one (held weakly).
@@ -357,9 +310,6 @@ class MainActivity : AppCompatActivity() {
// v1.8.8 "Atlas" (Workstreams D+E): clear the weakly-held Activity so PGS can't
// touch a destroyed Activity.
if (::playGames.isInitialized) playGames.attachActivity(null)
- // v2.0.3 "Harbor" (ADR 0025): drop the monetization gate's Activity reference. No-op
- // in the FOSS twin.
- if (::monetization.isInitialized) monetization.onDestroy()
}
/** v1.8.8 "Atlas" (Workstream L): finish a downloaded flexible update (restarts the
@@ -473,18 +423,14 @@ class MainActivity : AppCompatActivity() {
private fun onPauseSaveState() {
val ctrl = emulator.controller
val sha = emulator.romSha
- // RetroAchievements progress sidecar (v1.8.6) is persisted unconditionally —
- // it is unlock progress, not a save-state, so the freemium gate below does
- // not apply. A no-op when no RA session / game is loaded (empty blob).
+ // RetroAchievements progress sidecar (v1.8.6). A no-op when no RA session /
+ // game is loaded (empty blob).
if (ctrl != null && sha != null) {
runCatching {
val blob = ctrl.raSerializeProgress()
if (blob.isNotEmpty()) RaProgressStore.save(this, sha, blob)
}
}
- // Save-on-background is a paid feature in the Play build; sideload builds
- // (PLAY_BUILD=false) always persist. The demo never persists state.
- if (BuildConfig.PLAY_BUILD && (!::license.isInitialized || !license.isUnlocked)) return
if (ctrl != null && sha != null) {
runCatching { SaveStateStore.save(this, sha, SaveStateStore.AUTO_SLOT, ctrl.saveState()) }
// v1.8.8 "Atlas" (Workstream D): mirror the auto-resume slot to the cloud as
@@ -699,7 +645,6 @@ private fun loadRom(
bytes: ByteArray,
uri: Uri?,
name: String?,
- unlocked: Boolean,
settings: AppSettings,
): String {
val ctrl = NesController(bytes, 48_000u)
@@ -711,12 +656,9 @@ private fun loadRom(
GameConfig.filter(context, sha)?.let { f ->
settings.filter = VideoFilter.entries.getOrElse(f) { VideoFilter.None }
}
- // Auto-resume the on-background save-state is a paid feature; the demo always
- // cold-boots the ROM.
- if (unlocked) {
- SaveStateStore.load(context, sha, SaveStateStore.AUTO_SLOT)?.let { blob ->
- runCatching { ctrl.loadState(blob) }
- }
+ // Auto-resume the on-background save-state for this ROM if one is present.
+ SaveStateStore.load(context, sha, SaveStateStore.AUTO_SLOT)?.let { blob ->
+ runCatching { ctrl.loadState(blob) }
}
if (uri != null) {
runCatching {
@@ -868,15 +810,12 @@ private class AudioPlayer(sampleRate: Int) {
private fun EmulatorScreen(
emulator: EmulatorHandle,
gamepad: GamepadManager,
- license: LicenseManager,
settings: AppSettings,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activity = context as? Activity
// v1.8.8 "Atlas" (Workstream F/H): the typed host for PiP + deep-link + capture.
val host = context as? MainActivity
- // Freemium is active only in the Play build; sideload/dev builds are unlimited.
- val unlocked = !BuildConfig.PLAY_BUILD || license.isUnlocked
var frame by remember { mutableStateOf(null) }
// v1.8.8 "Atlas" (Workstream H): true while we are in the PiP window — drives the
// controls/HUD hide so only the gameplay picture shows in the floating window.
@@ -951,9 +890,6 @@ private fun EmulatorScreen(
var boxArtPreview by remember { mutableStateOf(null) }
// Folder batch-import progress (null = idle): (done, total).
var importProgress by remember { mutableStateOf?>(null) }
- // Demo session clock: seconds remaining this launch (full unlock = no limit).
- var demoSecondsLeft by remember { mutableStateOf(DEMO_SESSION_SECONDS) }
- var demoExpired by remember { mutableStateOf(false) }
// Settings are created at the theme root and passed in (v1.8.3).
// Drive the audio-mute flag from the persisted setting.
LaunchedEffect(settings.muted) { emulator.muted = settings.muted }
@@ -1146,7 +1082,7 @@ private fun EmulatorScreen(
val name = displayName(context, uri)
val bytes = (context.contentResolver.openInputStream(uri)
?: throw java.io.IOException("can't open ROM stream")).use { it.readBytes() }
- status = loadRom(context, emulator, bytes, uri, name, unlocked, settings)
+ status = loadRom(context, emulator, bytes, uri, name, settings)
recents = RomLibrary.recents(context)
}.onFailure { status = "Failed to load ROM: ${it.message}" }
}
@@ -1478,7 +1414,7 @@ private fun EmulatorScreen(
val uri = Uri.parse(rom.uri)
val bytes = (context.contentResolver.openInputStream(uri)
?: throw java.io.IOException("can't open recent ROM stream")).use { it.readBytes() }
- status = loadRom(context, emulator, bytes, uri, rom.name, unlocked, settings)
+ status = loadRom(context, emulator, bytes, uri, rom.name, settings)
recents = RomLibrary.recents(context)
libraryVersion++
}.onFailure { status = "Can't open ${rom.name}: ${it.message}" }
@@ -1496,7 +1432,7 @@ private fun EmulatorScreen(
val uri = Uri.parse(entry.uri)
val bytes = (context.contentResolver.openInputStream(uri)
?: throw java.io.IOException("can't open ROM stream")).use { it.readBytes() }
- status = loadRom(context, emulator, bytes, uri, entry.name, unlocked, settings)
+ status = loadRom(context, emulator, bytes, uri, entry.name, settings)
recents = RomLibrary.recents(context)
libraryVersion++
}.onFailure { status = "Can't open ${entry.name}: ${it.message}" }
@@ -1604,8 +1540,7 @@ private fun EmulatorScreen(
// just-pulled state into the LIVE controller — otherwise the user silently
// keeps playing the stale (pre-pull) state. So on a successful pull, re-read
// the (now-updated) local auto-slot and apply it, but only if the SAME ROM is
- // still loaded and auto-resume is allowed (unlocked; the demo cold-boots and
- // never auto-loads, so it must not be force-loaded here either).
+ // still loaded.
host.cloudSave.pullSlot(
sha,
SaveStateStore.AUTO_SLOT,
@@ -1614,7 +1549,7 @@ private fun EmulatorScreen(
onDone = { pulled ->
if (pulled) {
host.playGames.unlock(PgsIds.ACH_FIRST_CLOUD_SYNC)
- if (unlocked && emulator.romSha == sha) {
+ if (emulator.romSha == sha) {
scope.launch {
val blob = withContext(Dispatchers.IO) {
SaveStateStore.load(context, sha, SaveStateStore.AUTO_SLOT)
@@ -1648,26 +1583,6 @@ private fun EmulatorScreen(
host.deepLinkState.value = null
}
- // Demo countdown: tick once per second while a ROM is running, unpaused, and
- // not yet unlocked; on expiry, pause the emulator and raise the unlock sheet.
- // Purchasing (unlocked -> true) cancels the limit immediately.
- LaunchedEffect(unlocked) {
- if (unlocked) {
- demoExpired = false
- return@LaunchedEffect
- }
- while (true) {
- kotlinx.coroutines.delay(1000)
- if (emulator.controller != null && !emulator.paused && !demoExpired) {
- demoSecondsLeft -= 1
- if (demoSecondsLeft <= 0) {
- demoExpired = true
- emulator.paused = true
- }
- }
- }
- }
-
// RetroAchievements auto-login (v1.8.6): on first composition, if RA is enabled
// and a token was saved from a prior password login, init the session and
// token-login silently (fire-and-forget; status/toasts are polled in the loop).
@@ -1706,7 +1621,7 @@ private fun EmulatorScreen(
val auto = java.io.File(context.getExternalFilesDir(null), "autoload.nes")
if (auto.exists()) {
runCatching {
- status = loadRom(context, emulator, auto.readBytes(), null, "autoload", unlocked, settings)
+ status = loadRom(context, emulator, auto.readBytes(), null, "autoload", settings)
}.onFailure { status = "Autoload failed: ${it.message}" }
}
}
@@ -2034,10 +1949,7 @@ private fun EmulatorScreen(
modifier = Modifier.focusRequester(menuFocusRequester),
) { Text(stringResource(R.string.action_open)) }
}
- // Save-states are a paid feature; the demo hides the manager.
- if (unlocked) {
- OutlinedButton(onClick = { showStates = true }) { Text(stringResource(R.string.action_states)) }
- }
+ OutlinedButton(onClick = { showStates = true }) { Text(stringResource(R.string.action_states)) }
OutlinedButton(onClick = { emulator.controller?.reset() }) { Text(stringResource(R.string.action_reset)) }
OutlinedButton(onClick = {
paused = !paused
@@ -2107,29 +2019,6 @@ private fun EmulatorScreen(
factory = { ctx -> chromecast.mediaRouteButton(ctx) },
)
}
- // Demo: an always-visible unlock affordance + the session countdown.
- if (!unlocked) {
- // v2.0.1 (ADR 0025): read the flavor-neutral `priceLabel` façade rather
- // than the Google `ProductDetails` directly, so this src/main file links
- // no `com.android.billingclient.*` and compiles in the FOSS flavor.
- val price = license.priceLabel
- Button(onClick = { activity?.let { license.purchase(it) } }) {
- Text(stringResource(R.string.action_unlock, price))
- }
- val mins = demoSecondsLeft / 60
- val secs = demoSecondsLeft % 60
- Text(
- stringResource(R.string.demo_remaining, mins, secs),
- color = Color.Gray,
- )
- }
- // Debug-only (and only meaningful when the freemium is active, i.e. a
- // PLAY_BUILD debug build): simulate the Full Unlock without Play.
- if (BuildConfig.DEBUG && BuildConfig.PLAY_BUILD) {
- OutlinedButton(onClick = { license.debugForceUnlocked(!unlocked) }) {
- Text(if (unlocked) "DBG:demo" else "DBG:unlock")
- }
- }
}
} // end control bar (toggled by the RustyNES pill)
@@ -2420,16 +2309,6 @@ private fun EmulatorScreen(
)
}
- // Demo-expired gate: a blocking sheet over everything with Unlock + Restore.
- if (!unlocked && demoExpired) {
- DemoExpiredOverlay(
- // v2.0.1 (ADR 0025): flavor-neutral price façade (see the demo unlock button).
- price = license.priceLabel,
- onUnlock = { activity?.let { license.purchase(it) } },
- onRestore = { license.refreshEntitlement() },
- )
- }
-
// Emulation loop: run frames + render audio on a background dispatcher, then
// publish each frame to Compose. Pacing is audio-clocked when sound is present
// (the blocking AudioTrack write paces the loop to real time) with a wall-clock
@@ -2692,34 +2571,6 @@ private fun packRgbaToArgb(rgba: ByteArray, out: IntArray) {
}
-/** Blocking sheet shown when the free 10-minute demo session expires. */
-@Composable
-private fun DemoExpiredOverlay(price: String, onUnlock: () -> Unit, onRestore: () -> Unit) {
- Box(
- modifier = Modifier.fillMaxSize().background(Color(0xE6000000)),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- modifier = Modifier.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text("Demo time's up", color = Color.White)
- Spacer(Modifier.height(8.dp))
- Text(
- "Unlock the full version to keep playing — save states, resume, " +
- "and in-cart battery saves included.",
- color = Color.LightGray,
- )
- Spacer(Modifier.height(20.dp))
- Button(onClick = onUnlock) { Text("Unlock $price") }
- Spacer(Modifier.height(8.dp))
- androidx.compose.material3.TextButton(onClick = onRestore) {
- Text("Restore purchase")
- }
- }
- }
-}
-
// The on-screen controls now live in `VirtualController.kt` — a single multi-touch
// Canvas (the old per-button `TouchOverlay`/`PadButton` registered one input at a
// time and was replaced in v1.8.2).
diff --git a/android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt b/android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt
index 19178b35..97da8e24 100644
--- a/android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt
+++ b/android/app/src/main/java/com/doublegate/rustynes/PlayFacadeShared.kt
@@ -4,11 +4,14 @@ package com.doublegate.rustynes
* Shared, flavor-neutral declarations for the `foss` / `play` façade split (v2.0.1,
* ADR 0025).
*
- * Everything here is **pure Kotlin / AOSP** — no `com.google.*`, no
- * `com.android.billingclient.*`, no ads — so it lives in `src/main` and is visible to
- * BOTH the `foss` and `play` source sets. The Google-Play-specific glue (Billing,
- * Play Games, Play Integrity, Cast framework, in-app update/review) lives in
- * `src/play/`; the byte-for-byte-API-compatible no-op stand-ins live in `src/foss/`.
+ * Everything here is **pure Kotlin / AOSP** — no `com.google.*`, no ads, no billing —
+ * so it lives in `src/main` and is visible to BOTH the `foss` and `play` source sets.
+ * The Google-Play-specific glue (Play Games, Play Integrity, Cast framework, in-app
+ * update/review — all free, no monetization) lives in `src/play/`; the
+ * byte-for-byte-API-compatible no-op stand-ins live in `src/foss/`.
+ *
+ * RustyNES is permanently open-source and income-free (ADR 0035): there is no Billing,
+ * no ads, no freemium, and no paid unlock in any flavor.
*
* These declarations moved OUT of the proprietary glue files (which are now
* `play`-only) precisely because `MainActivity` (a `src/main` file) references them —
@@ -16,17 +19,6 @@ package com.doublegate.rustynes
* are channel-independent, so a single definition serves both flavors.
*/
-/**
- * Free-tier demo session length: 8 minutes (shortened in debug for testing).
- *
- * Read by `MainActivity`'s demo countdown, so it must be visible in both flavors. In
- * the `foss` build there is no Billing / freemium at all (`PLAY_BUILD == false`), so
- * the demo gate is inert and this value is never actually counted down against; it is
- * kept here only to satisfy the shared reference. The `play` freemium (Workstream M)
- * is the sole consumer that matters.
- */
-val DEMO_SESSION_SECONDS: Int = if (BuildConfig.DEBUG) 60 else 480
-
/**
* Play Games Services achievement / leaderboard ids (v1.8.8 "Atlas", Workstream E).
*
@@ -67,8 +59,8 @@ object PgsIds {
* A pure enum with no Google dependency, so it is shared. On-device this is always
* [UNKNOWN]: the `play` `IntegrityManager` cannot decrypt the token locally (that is
* the maintainer's server endpoint), and the `foss` `IntegrityManager` façade never
- * requests a token at all. The app treats UNKNOWN as "no signal" — Billing remains the
- * entitlement truth, so nothing is ever revoked on the strength of this.
+ * requests a token at all. The app treats UNKNOWN as "no signal"; nothing is gated on
+ * it (there is no entitlement to revoke — every feature is free).
*/
enum class IntegrityVerdict {
/** Verdict not yet available (flag off, no cloud project, no server endpoint, or
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 0da6d1a9..4774a570 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -55,8 +55,6 @@
No se encontró carátula para %1$s. Elige una imagen.
Importando %1$d de %2$d…
%1$d juegos importados
- Desbloquear %1$s
- Demo · %1$d:%2$02d
Abre una ROM .nes para empezar
Sin ROM cargada
Enviando a %1$s
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index bc89f5b4..8ea48af5 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -61,10 +61,6 @@
Importing %1$d of %2$d…
Imported %1$d games
-
- Unlock %1$s
-
- Demo · %1$d:%2$02d
Open a .nes ROM to start
No ROM loaded
diff --git a/android/app/src/play/AndroidManifest.xml b/android/app/src/play/AndroidManifest.xml
index d5937939..f743dcec 100644
--- a/android/app/src/play/AndroidManifest.xml
+++ b/android/app/src/play/AndroidManifest.xml
@@ -1,30 +1,13 @@
-
-
-
-
-
-
-
-
diff --git a/android/app/src/play/java/com/doublegate/rustynes/Billing.kt b/android/app/src/play/java/com/doublegate/rustynes/Billing.kt
deleted file mode 100644
index b670fc17..00000000
--- a/android/app/src/play/java/com/doublegate/rustynes/Billing.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-// PLAY-FLAVOR SOURCE SET (v2.0.1, ADR 0025). This is the real, Google-Play-Billing-
-// backed `LicenseManager`. The `foss` variant supplies a byte-compatible no-op twin in
-// `src/foss/.../Billing.kt` (no `com.android.billingclient.*` import), so the FOSS /
-// F-Droid artifact links no Billing SDK and the demo/freemium is simply absent there.
-package com.doublegate.rustynes
-
-import android.app.Activity
-import android.content.Context
-import android.util.Log
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import com.android.billingclient.api.AcknowledgePurchaseParams
-import com.android.billingclient.api.BillingClient
-import com.android.billingclient.api.BillingClientStateListener
-import com.android.billingclient.api.BillingResult
-import com.android.billingclient.api.PendingPurchasesParams
-import com.android.billingclient.api.ProductDetails
-import com.android.billingclient.api.Purchase
-import com.android.billingclient.api.PurchasesUpdatedListener
-import com.android.billingclient.api.QueryProductDetailsParams
-import com.android.billingclient.api.QueryPurchasesParams
-
-/** The one-time, non-consumable "Full Unlock" product id (set up in Play Console). */
-const val FULL_UNLOCK_PRODUCT = "full_unlock"
-
-// DEMO_SESSION_SECONDS moved to `src/main/.../PlayFacadeShared.kt` (v2.0.1) so the
-// value is visible to both flavors; the freemium demo it feeds is `play`-only.
-
-/**
- * Owns the freemium entitlement (Workstream M).
- *
- * Free download + a one-time, non-consumable in-app purchase ("Full Unlock",
- * $2.99) via Play Billing. [isUnlocked] is Compose-observable; the shell reads it
- * to gate the demo (save-states / resume / SRAM persistence + the session timer).
- *
- * The local cache (`SharedPreferences`) makes the unlocked state available
- * instantly and offline, but Play is the source of truth: every connection
- * re-queries `queryPurchasesAsync`, so a refund/clear flips the entitlement back.
- * A non-consumable purchase is owned forever and restored automatically across
- * reinstall / new device (no server needed).
- */
-class LicenseManager(private val appContext: Context) {
-
- /** True once the Full Unlock is owned (or forced in a debug build). */
- var isUnlocked by mutableStateOf(false)
- private set
-
- /** The fetched product (for its localized price + the purchase flow). */
- var product by mutableStateOf(null)
- private set
-
- /**
- * The localized "Full Unlock" price to show in the UI, or the `$2.99` fallback
- * before the product loads (v2.0.1 façade). Exposed as a plain `String` — NOT the
- * Google `ProductDetails` — so `MainActivity` (a `src/main` file) never touches a
- * `com.android.billingclient.*` type; that is what lets the `foss` twin present
- * the identical `priceLabel` surface while linking no Billing SDK. Reading the
- * `product` state inside the getter keeps the label reactive under Compose.
- */
- val priceLabel: String
- get() = product?.oneTimePurchaseOfferDetails?.formattedPrice ?: "$2.99"
-
- private val prefs = appContext.getSharedPreferences("license", Context.MODE_PRIVATE)
-
- private val purchasesListener = PurchasesUpdatedListener { result, purchases ->
- if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
- purchases.forEach(::handlePurchase)
- }
- }
-
- private val client: BillingClient = BillingClient.newBuilder(appContext)
- .setListener(purchasesListener)
- .enablePendingPurchases(
- PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
- )
- .build()
-
- init {
- // Optimistic offline value; Play re-verifies on connect.
- isUnlocked = prefs.getBoolean("unlocked", false)
- }
-
- /** Connect to Play and refresh the product + entitlement. Idempotent. */
- fun connect() {
- if (client.connectionState == BillingClient.ConnectionState.CONNECTED) {
- queryProduct(); refreshEntitlement(); return
- }
- client.startConnection(object : BillingClientStateListener {
- override fun onBillingSetupFinished(result: BillingResult) {
- if (result.responseCode == BillingClient.BillingResponseCode.OK) {
- queryProduct()
- refreshEntitlement()
- }
- }
- override fun onBillingServiceDisconnected() {
- Log.i("RustyNES", "Billing disconnected")
- }
- })
- }
-
- /** Re-query owned purchases (also the "Restore purchase" action). */
- fun refreshEntitlement() {
- client.queryPurchasesAsync(
- QueryPurchasesParams.newBuilder()
- .setProductType(BillingClient.ProductType.INAPP)
- .build(),
- ) { _, purchases ->
- val owned = purchases.any {
- it.products.contains(FULL_UNLOCK_PRODUCT) &&
- it.purchaseState == Purchase.PurchaseState.PURCHASED
- }
- applyUnlocked(owned)
- purchases.forEach(::handlePurchase)
- }
- }
-
- private fun queryProduct() {
- val params = QueryProductDetailsParams.newBuilder()
- .setProductList(
- listOf(
- QueryProductDetailsParams.Product.newBuilder()
- .setProductId(FULL_UNLOCK_PRODUCT)
- .setProductType(BillingClient.ProductType.INAPP)
- .build(),
- ),
- )
- .build()
- client.queryProductDetailsAsync(params) { _, result ->
- product = result.productDetailsList.firstOrNull()
- }
- }
-
- /** Launch the Play purchase flow for the Full Unlock. */
- fun purchase(activity: Activity) {
- val details = product ?: return
- val flowParams = com.android.billingclient.api.BillingFlowParams.newBuilder()
- .setProductDetailsParamsList(
- listOf(
- com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
- .newBuilder()
- .setProductDetails(details)
- .build(),
- ),
- )
- .build()
- client.launchBillingFlow(activity, flowParams)
- }
-
- private fun handlePurchase(p: Purchase) {
- if (p.products.contains(FULL_UNLOCK_PRODUCT) &&
- p.purchaseState == Purchase.PurchaseState.PURCHASED
- ) {
- applyUnlocked(true)
- // A non-consumable purchase must be acknowledged within 3 days or
- // Play auto-refunds it.
- if (!p.isAcknowledged) {
- client.acknowledgePurchase(
- AcknowledgePurchaseParams.newBuilder()
- .setPurchaseToken(p.purchaseToken)
- .build(),
- ) { /* acknowledged */ }
- }
- }
- }
-
- private fun applyUnlocked(value: Boolean) {
- isUnlocked = value
- prefs.edit().putBoolean("unlocked", value).apply()
- }
-
- /**
- * Debug-only override so the demo gating + unlock UI can be exercised on a
- * sideloaded build without a Play Console / license-tested account (the real
- * purchase flow can't run on a sideloaded APK). No-op in release.
- */
- fun debugForceUnlocked(value: Boolean) {
- if (BuildConfig.DEBUG) applyUnlocked(value)
- }
-}
diff --git a/android/app/src/play/java/com/doublegate/rustynes/Integrity.kt b/android/app/src/play/java/com/doublegate/rustynes/Integrity.kt
index 919ce948..51153f14 100644
--- a/android/app/src/play/java/com/doublegate/rustynes/Integrity.kt
+++ b/android/app/src/play/java/com/doublegate/rustynes/Integrity.kt
@@ -13,31 +13,33 @@ import com.google.android.play.core.integrity.StandardIntegrityManager.StandardI
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
/**
- * Play Integrity API client (v1.8.8 "Atlas", Workstream L) — the anti-tamper /
- * anti-piracy layer that confirms a genuine, uncompromised, Play-recognized binary
- * BEFORE honoring/restoring the Full Unlock. (SafetyNet Attestation was turned down
- * January 2025; Play Integrity is the modern replacement.)
+ * Play Integrity API client (v1.8.8 "Atlas", Workstream L) — an optional device /
+ * app-integrity signal that confirms a genuine, uncompromised, Play-recognized binary.
+ * (SafetyNet Attestation was turned down January 2025; Play Integrity is the modern
+ * replacement.)
+ *
+ * RustyNES is permanently open-source and income-free, so there is nothing to gate on
+ * this: it carries NO billing, NO in-app purchase, and NO paywall. The client is a
+ * pure, informational health signal that the app NEVER blocks function on.
*
* PREPPED BEHIND A DEFAULT-OFF FLAG. Nothing here touches the Integrity SDK unless
* [BuildConfig.PLAY_INTEGRITY_ENABLED] is true AND a non-zero
* [BuildConfig.INTEGRITY_CLOUD_PROJECT_NUMBER] is set. With the flag off (the default,
* and on every sideload build), [request] is a cheap no-op.
*
- * ## Defense-in-depth, NOT the entitlement source of truth
- * **Play Billing stays the source of truth** for the $2.99 Full Unlock
- * (`queryPurchasesAsync` in [LicenseManager]). Integrity is a LAYER over it: a failed
- * or absent verdict must NEVER revoke a legitimate purchase — at worst it is a signal
- * the maintainer's server can weigh when deciding whether to honor a *restore*. The
- * app never blocks function on the verdict.
+ * ## Advisory only, never a gate
+ * A failed or absent verdict must NEVER restrict any feature — every feature is free
+ * and unconditional. At most the verdict is a signal a maintainer's server could log.
+ * The app never blocks function on it.
*
* ## Why the verdict handler is a STUB (maintainer ops)
* Play Integrity returns an **encrypted, signed token**. Decrypting + verifying it
* requires the maintainer's **linked Google Cloud project + a server endpoint** (the
* verdict is meant to be evaluated server-side; never trusted on-device). This client
* requests the token and hands the opaque string to [onToken]; the
- * `MEETS_DEVICE_INTEGRITY` / `PLAY_RECOGNIZED` / `appLicensingVerdict == LICENSED`
- * checks live in that server, which the maintainer wires up. The on-device
- * [evaluateStub] is a clearly-marked placeholder that returns [IntegrityVerdict.UNKNOWN].
+ * `MEETS_DEVICE_INTEGRITY` / `PLAY_RECOGNIZED` checks live in that server, which the
+ * maintainer wires up. The on-device [evaluateStub] is a clearly-marked placeholder
+ * that returns [IntegrityVerdict.UNKNOWN].
*
* Uses the **Standard** request (warmed [prepareToken], replay-protected, few-hundred-
* ms) per the Play Integrity guidance, not the deprecated Classic request.
@@ -79,9 +81,9 @@ class IntegrityManager(context: Context) {
/**
* Request an integrity token for a [requestHash] (bind it to the action being
- * protected, e.g. a Full-Unlock restore; the server replays this hash). [onToken]
- * receives the opaque, encrypted token string for the maintainer's server to
- * decrypt + evaluate. No-op (does not call back) when not configured / not warmed.
+ * checked; the server replays this hash). [onToken] receives the opaque, encrypted
+ * token string for the maintainer's server to decrypt + evaluate. No-op (does not
+ * call back) when not configured / not warmed.
*/
fun request(requestHash: String, onToken: (String) -> Unit) {
if (!configured()) return
@@ -104,10 +106,10 @@ class IntegrityManager(context: Context) {
/**
* MAINTAINER-OPS STUB. The real verdict is produced by decrypting [token] on the
* maintainer's server (the linked Cloud project's verdict-decryption endpoint) and
- * checking `MEETS_DEVICE_INTEGRITY` + `PLAY_RECOGNIZED` + `appLicensingVerdict`.
- * On-device we cannot (and must not) decrypt it, so this always returns
- * [IntegrityVerdict.UNKNOWN]. The app treats UNKNOWN as "no signal" — Billing
- * remains the entitlement truth, so nothing is revoked.
+ * checking `MEETS_DEVICE_INTEGRITY` + `PLAY_RECOGNIZED`. On-device we cannot (and
+ * must not) decrypt it, so this always returns [IntegrityVerdict.UNKNOWN]. The app
+ * treats UNKNOWN as "no signal" and never restricts any feature — every feature is
+ * free and unconditional.
*
* To wire it up, the maintainer POSTs [token] to their endpoint and maps the
* decrypted verdict back to GENUINE / TAMPERED here.
diff --git a/android/app/src/play/java/com/doublegate/rustynes/MonetizationGate.kt b/android/app/src/play/java/com/doublegate/rustynes/MonetizationGate.kt
deleted file mode 100644
index 4cd83c21..00000000
--- a/android/app/src/play/java/com/doublegate/rustynes/MonetizationGate.kt
+++ /dev/null
@@ -1,310 +0,0 @@
-// PLAY-FLAVOR SOURCE SET (v2.0.3, ADR 0025). The real freemium / ad-supported
-// monetization façade. The `foss` twin (src/foss/.../MonetizationGate.kt) is a byte-
-// compatible no-op that links no ads / store SDK, so the shared `MainActivity`
-// (src/main) compiles against either flavor and the FOSS build stays behaviourally
-// identical to the pre-monetization build.
-//
-// This file concentrates every proprietary monetization dependency the shared code must
-// NOT see: it initializes AppLovin MAX + RevenueCat (adapting the RustyNesApp shell's
-// process-init responsibilities) and owns the `AdPolicy` Rust core, the RevenueCat
-// entitlement wrapper (`RcBilling`), and the interstitial / rewarded gates. All policy
-// (ad cadence, feature gating, the free-tier play budget, offline grace) lives in the
-// shared Rust core so Android and iOS cannot diverge; this class is only the platform
-// wiring + the run-out paywall UI.
-package com.doublegate.rustynes
-
-import android.app.Activity
-import android.content.Context
-import android.os.SystemClock
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableLongStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.applovin.sdk.AppLovinMediationProvider
-import com.applovin.sdk.AppLovinSdk
-import com.applovin.sdk.AppLovinSdkInitializationConfiguration
-import com.doublegate.rustynes.monetization.AdGate
-import com.doublegate.rustynes.monetization.RcBilling
-import com.doublegate.rustynes.monetization.RewardedGate
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.doublegate.rustynes.monetization.ffi.PlayProgress
-import com.doublegate.rustynes.monetization.ffi.PremiumFeature
-import com.doublegate.rustynes.monetization.ffi.defaultAdConfig
-import com.revenuecat.purchases.LogLevel
-import com.revenuecat.purchases.Purchases
-import com.revenuecat.purchases.PurchasesConfiguration
-import kotlinx.coroutines.delay
-
-/**
- * The `play`-flavor monetization gate.
- *
- * Construction is cheap (it builds only the Rust [AdPolicy] core, which does no I/O). The
- * heavy SDK initialization (AppLovin, RevenueCat) runs once, guarded by [sdksInitialized],
- * on the first [onActivityCreated] call — which `MainActivity` invokes from `onCreate`, so
- * it is on the launch path; posting it off the first-frame critical path is a v2.0.9
- * refinement (like the already-deferred Play Billing connect).
- *
- * The core is anchored to `SystemClock.elapsedRealtime()` — the monotonic millisecond clock
- * the [AdGate] / [RewardedGate] also read — so the launch-grace window and ad cooldown share
- * one timebase.
- */
-class MonetizationGate(private val appContext: Context) {
-
- /** The process-wide policy core (a UniFFI object over the pure-Rust `AdPolicy`). */
- private val core: AdPolicy =
- AdPolicy(defaultAdConfig(), SystemClock.elapsedRealtime().toULong())
-
- /** RevenueCat entitlement wrapper; created once the SDK is configured. */
- private var billing: RcBilling? = null
-
- /** Interstitial + rewarded gates; created once an Activity is available. */
- private var adGate: AdGate? = null
- private var rewardedGate: RewardedGate? = null
-
- /** The foreground Activity (needed to launch ads / the purchase dialog). */
- private var activityRef: Activity? = null
-
- /** Guards one-time SDK init (AppLovin + RevenueCat configure). */
- private var sdksInitialized = false
-
- /**
- * Bridges the asynchronous rewarded-ad completion back to the paused emulator:
- * [RunOutOverlay] stores its Compose `onResume` lambda here, and the [RewardedGate]'s
- * reward callback invokes it. The gate's `show()` is non-blocking (it returns before the
- * ad finishes), so the resume action cannot be run inline at the call site.
- */
- private var onResumeCallback: (() -> Unit)? = null
-
- private val prefs = appContext.getSharedPreferences("monetization", Context.MODE_PRIVATE)
-
- /**
- * Begin an app session: increment the persisted app-session index and hand it to the
- * core (drives first-session interstitial suppression + the generous first-session
- * budget). Call once at launch.
- */
- fun beginSession() {
- val next = prefs.getInt(KEY_SESSION_INDEX, 0) + 1
- prefs.edit().putInt(KEY_SESSION_INDEX, next).apply()
- core.beginSession(next.toUInt(), SystemClock.elapsedRealtime().toULong())
- }
-
- /**
- * One-time SDK init + gate creation, deferred off cold start. Idempotent: the SDKs are
- * configured only on the first call; the ad gates are (re)bound to the current Activity.
- */
- fun onActivityCreated(activity: Activity) {
- activityRef = activity
- if (!sdksInitialized) {
- // The play flavor is always PLAY_BUILD; guard kept for parity with the shell.
- if (BuildConfig.PLAY_BUILD) {
- val initConfig = AppLovinSdkInitializationConfiguration
- .builder(BuildConfig.APPLOVIN_SDK_KEY, appContext)
- .setMediationProvider(AppLovinMediationProvider.MAX)
- .build()
- AppLovinSdk.getInstance(appContext).initialize(initConfig) { /* SDK ready */ }
-
- Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
- Purchases.configure(
- PurchasesConfiguration.Builder(appContext, BuildConfig.REVENUECAT_API_KEY)
- .build(),
- )
- billing = RcBilling(core).also { it.bindEntitlement() }
- }
- sdksInitialized = true
- }
- // (Re)create the ad gates bound to this Activity and warm their caches. The rewarded
- // gate's reward callback fires asynchronously (after the ad is watched), so it routes
- // through `onResumeCallback` — set by RunOutOverlay — to unpause the emulator.
- adGate = AdGate(activity, core).also { it.preload() }
- rewardedGate = RewardedGate(activity, core) { onResumeCallback?.invoke() }
- .also { it.preload() }
- }
-
- /** Foreground: re-verify the entitlement against RevenueCat (a purchase/refund elsewhere). */
- fun onResume(activity: Activity) {
- activityRef = activity
- billing?.bindEntitlement()
- }
-
- /**
- * Drop the held Activity and detach the ad listeners, so a destroyed Activity (e.g. on a
- * rotation) is neither touched nor retained by the long-lived AppLovin ad instances.
- */
- fun onDestroy() {
- activityRef = null
- adGate?.destroy()
- adGate = null
- rewardedGate?.destroy()
- rewardedGate = null
- }
-
- /** Whether [feature] is unlocked for the current entitlement (delegates to the core). */
- fun featureEnabled(feature: PremiumFeature): Boolean = core.featureEnabled(feature)
-
- /** Reset the per-game free-tier play budget (call when a ROM loads). */
- fun startPlay() = core.startPlay()
-
- /** Feed elapsed unpaused play time into the free-tier budget. */
- fun addActiveTime(deltaMs: Long) = core.addActiveTime(deltaMs.toULong())
-
- /** Whether play is currently allowed (false once a free user's budget is exhausted). */
- fun isPlayAllowed(): Boolean = core.isPlayAllowed()
-
- /**
- * Persist the current free-tier play progress keyed by [romKey], so backgrounding then
- * relaunching a free session cannot reset the budget. No-op for premium (nothing to gate).
- */
- fun exportProgress(romKey: String) {
- val p = core.exportProgress()
- prefs.edit()
- .putLong(progressKey(romKey, "budget"), p.budgetMs.toLong())
- .putLong(progressKey(romKey, "consumed"), p.consumedMs.toLong())
- .putInt(progressKey(romKey, "grants"), p.rewardGrantsThisSession.toInt())
- .putBoolean(progressKey(romKey, "grace"), p.offlineGraceUsed)
- .apply()
- }
-
- /** Restore a previously [exportProgress]'d snapshot for [romKey] (no-op if none stored). */
- fun restoreProgress(romKey: String) {
- if (!prefs.contains(progressKey(romKey, "budget"))) return
- core.restoreProgress(
- PlayProgress(
- budgetMs = prefs.getLong(progressKey(romKey, "budget"), 0L).toULong(),
- consumedMs = prefs.getLong(progressKey(romKey, "consumed"), 0L).toULong(),
- rewardGrantsThisSession = prefs.getInt(progressKey(romKey, "grants"), 0).toUInt(),
- offlineGraceUsed = prefs.getBoolean(progressKey(romKey, "grace"), false),
- ),
- )
- }
-
- /**
- * The run-out paywall + countdown overlay.
- *
- * A free user sees a live mm:ss countdown of their remaining budget; when it hits zero
- * a modal offers the three continuation paths the core arbitrates — watch a rewarded ad
- * for more time (only while `canOfferRewarded()`), buy the Full Version (RevenueCat), or
- * a one-time offline-grace continuation when no ad is available. Premium users and the
- * FOSS build (its twin) never draw anything.
- *
- * Drive-by-tick: the [AdPolicy] core is not Compose-observable, so a 1 Hz [LaunchedEffect]
- * samples `playTimeRemainingMs()` into Compose state — cheap, and keeps the overlay's
- * recomposition scope tight (only the countdown text and the paywall visibility read it).
- *
- * @param onResume invoked after a granted reward / purchase / grace so the caller resumes
- * the paused emulator.
- */
- @Composable
- fun RunOutOverlay(onResume: () -> Unit) {
- // Bridge the async rewarded-ad / purchase / grace resume back to the caller.
- onResumeCallback = onResume
- var remainingMs by remember { mutableLongStateOf(0L) }
- var premium by remember { mutableStateOf(core.isPremium()) }
- // Keyed on `premium`: a premium (or newly-upgraded) user needs no ticking countdown,
- // so short-circuit immediately — no 1 Hz wakeups / battery drain when nothing is gated.
- LaunchedEffect(premium) {
- if (premium) return@LaunchedEffect
- while (true) {
- premium = core.isPremium()
- remainingMs = core.playTimeRemainingMs()?.toLong() ?: Long.MAX_VALUE
- delay(1000L)
- }
- }
- // Premium (or an unlimited/unmetered session): draw nothing.
- if (premium || remainingMs == Long.MAX_VALUE) return
-
- // Still within budget: a lightweight top-anchored countdown (mm:ss) so the free
- // user sees time ticking down toward the run-out paywall.
- if (core.isPlayAllowed()) {
- Column(
- modifier = Modifier.fillMaxSize().padding(top = 12.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = stringResource(R.string.paywall_countdown, formatMmSs(remainingMs)),
- style = MaterialTheme.typography.labelLarge,
- color = Color.White,
- )
- }
- return
- }
-
- // Budget exhausted: the full run-out paywall modal.
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(alpha = 0.85f))
- .padding(24.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- text = stringResource(R.string.paywall_runout_title),
- style = MaterialTheme.typography.headlineSmall,
- color = Color.White,
- )
- Text(
- text = stringResource(R.string.paywall_runout_body),
- style = MaterialTheme.typography.bodyMedium,
- color = Color.White,
- )
- if (core.canOfferRewarded()) {
- Button(onClick = {
- // A granted reward resumes via the gate's callback; the boolean tells us
- // whether an ad was actually ready (else fall through to purchase/grace).
- if (rewardedGate?.show() != true) { /* no ad ready: user can buy/grace */ }
- }) {
- Text(stringResource(R.string.paywall_watch_ad))
- }
- }
- if (core.canGrantOfflineGrace()) {
- Button(onClick = {
- if (core.grantOfflineGrace()) onResume()
- }) {
- Text(stringResource(R.string.paywall_offline_grace))
- }
- }
- Button(onClick = {
- val act = activityRef ?: return@Button
- billing?.purchasePremium(act) { isPremium, _ ->
- if (isPremium) {
- premium = true
- onResume()
- }
- }
- }) {
- Text(stringResource(R.string.paywall_full_version))
- }
- }
- }
-
- private fun progressKey(romKey: String, field: String): String = "prog_${romKey}_$field"
-
- /** Format a non-negative millisecond duration as `m:ss` for the countdown chip. */
- private fun formatMmSs(ms: Long): String {
- val totalSeconds = (ms.coerceAtLeast(0L)) / 1000L
- // Force Locale.US so the countdown always uses ASCII digits — a device in a
- // locale with non-ASCII numerals (Arabic/Persian) would otherwise render digits
- // the label font may not support.
- return "%d:%02d".format(java.util.Locale.US, totalSeconds / 60L, totalSeconds % 60L)
- }
-
- companion object {
- private const val KEY_SESSION_INDEX = "app_session_index"
- }
-}
diff --git a/android/app/src/play/java/com/doublegate/rustynes/monetization/AdGate.kt b/android/app/src/play/java/com/doublegate/rustynes/monetization/AdGate.kt
deleted file mode 100644
index 7385854e..00000000
--- a/android/app/src/play/java/com/doublegate/rustynes/monetization/AdGate.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * AdGate.kt — AppLovin MAX interstitial gate (PLAY-FLAVOR ONLY, v2.0.3, ADR 0025).
- *
- * Adapted verbatim in behaviour from the reference shell at
- * `crates/rustynes-monetization/shells/android/AdGate.kt`. It lives ONLY in the `play`
- * source set: it imports `com.applovin.*`, which the `foss` (F-Droid / GitHub-Releases)
- * artifact deliberately links none of. The `foss` twin needs no counterpart because the
- * shared `MonetizationGate` façade — real in `play`, no-op in `foss` — is the only class
- * `MainActivity` (src/main) touches; the ad gates are an internal `play`-side detail.
- *
- * The gate owns the MAX interstitial lifecycle (preload -> show -> reload) but defers
- * EVERY policy question to the shared Rust core (`AdPolicy`, a UniFFI object):
- * - shouldShowInterstitial(nowMs) decides if a break point is eligible
- * - notifyInterstitialShown(nowMs) arms the cooldown, only after a real display
- *
- * Because the identical core drives the iOS gate, the two platforms cannot diverge on
- * cadence. The gate also no-ops automatically for premium users, since the core's
- * shouldShowInterstitial returns false whenever premium is set.
- */
-package com.doublegate.rustynes.monetization
-
-import android.app.Activity
-import android.os.SystemClock
-import com.applovin.mediation.MaxAd
-import com.applovin.mediation.MaxAdListener
-import com.applovin.mediation.MaxError
-import com.applovin.mediation.ads.MaxInterstitialAd
-import com.doublegate.rustynes.BuildConfig
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-
-/**
- * Interstitial gate. Construct once per Activity with the process-wide [AdPolicy] core.
- * Every showing is gated by the core at an explicit break point, never auto-shown.
- */
-class AdGate(
- private val activity: Activity,
- private val core: AdPolicy,
-) : MaxAdListener {
-
- private val interstitial: MaxInterstitialAd =
- MaxInterstitialAd(BuildConfig.MAX_INTERSTITIAL_AD_UNIT_ID, activity).apply {
- setListener(this@AdGate)
- }
-
- /** Monotonic ms, matching the clock model the Rust core was constructed with. */
- private fun nowMs(): ULong = SystemClock.elapsedRealtime().toULong()
-
- /** Warm the cache so a later show() is instant. Safe to call once after init. */
- fun preload() {
- if (!core.isPremium()) interstitial.loadAd()
- }
-
- /**
- * Show an interstitial iff the core allows it right now. Premium users, the launch
- * grace window, and the inter-ad cooldown are all handled inside the core, so the
- * caller can invoke this freely at any natural break point.
- */
- fun maybeShowInterstitial() {
- if (!core.shouldShowInterstitial(nowMs())) return
- if (interstitial.isReady) {
- interstitial.showAd()
- } else {
- interstitial.loadAd() // not cached yet; show on the next break point
- }
- }
-
- // --- MaxAdListener -------------------------------------------------------------
-
- override fun onAdLoaded(ad: MaxAd) {
- // Loaded and cached. We deliberately do NOT auto-show here; showing is gated by
- // the core at an explicit break point via maybeShowInterstitial().
- }
-
- override fun onAdDisplayed(ad: MaxAd) {
- // The ad is actually on screen — arm the cooldown now (not at decision time),
- // so a failed load never burns the interval.
- core.notifyInterstitialShown(nowMs())
- }
-
- override fun onAdHidden(ad: MaxAd) {
- // User dismissed the ad; immediately reload for the next eligible break.
- if (!core.isPremium()) interstitial.loadAd()
- }
-
- override fun onAdClicked(ad: MaxAd) { /* no-op */ }
-
- override fun onAdLoadFailed(adUnitId: String, error: MaxError) {
- // Optional: implement exponential backoff before retrying loadAd().
- }
-
- override fun onAdDisplayFailed(ad: MaxAd, error: MaxError) {
- // Display failed; reload so the next break point can try again.
- if (!core.isPremium()) interstitial.loadAd()
- }
-
- /**
- * Detach the listener so this gate (and the Activity it holds) is not retained by the
- * long-lived `MaxInterstitialAd`. Call from the owner's `onDestroy` (e.g. a rotation).
- */
- fun destroy() {
- interstitial.setListener(null)
- }
-}
diff --git a/android/app/src/play/java/com/doublegate/rustynes/monetization/RcBilling.kt b/android/app/src/play/java/com/doublegate/rustynes/monetization/RcBilling.kt
deleted file mode 100644
index e9e6d155..00000000
--- a/android/app/src/play/java/com/doublegate/rustynes/monetization/RcBilling.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * RcBilling.kt — RevenueCat entitlement wrapper (PLAY-FLAVOR ONLY, v2.0.3, ADR 0025).
- *
- * Adapted from `crates/rustynes-monetization/shells/android/Billing.kt`. Named `RcBilling`
- * (RevenueCat billing) to keep it unambiguously distinct from the pre-existing Play-Billing
- * `LicenseManager` (in `src/play/.../Billing.kt`, package `com.doublegate.rustynes`), which
- * drives the older one-time "$2.99 Full Unlock" IAP. This wrapper drives the ad-supported /
- * remove-ads monetization pivot (ADR 0025 + the 2026-06-23 pivot): a single RevenueCat
- * "premium" entitlement toggles every gate in the shared Rust core.
- *
- * This is the *only* place that knows about the store. It translates RevenueCat's
- * `CustomerInfo` into a single boolean (premium yes/no) and pushes it into the shared Rust
- * core via `AdPolicy.setPremium`. Everything else — including whether ads show — derives
- * from that one flag, so there is no second source of truth.
- *
- * Lives only in the `play` source set (imports `com.revenuecat.*`). The `foss` build links
- * no store SDK and ships premium-equivalent (everything free), so it needs no counterpart.
- */
-package com.doublegate.rustynes.monetization
-
-import android.app.Activity
-import com.doublegate.rustynes.BuildConfig
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.revenuecat.purchases.CustomerInfo
-import com.revenuecat.purchases.PackageType
-import com.revenuecat.purchases.Purchases
-import com.revenuecat.purchases.PurchasesError
-import com.revenuecat.purchases.PurchaseParams
-import com.revenuecat.purchases.getCustomerInfoWith
-import com.revenuecat.purchases.getOfferingsWith
-import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
-import com.revenuecat.purchases.models.StoreTransaction
-import com.revenuecat.purchases.purchaseWith
-import com.revenuecat.purchases.restorePurchasesWith
-
-class RcBilling(private val core: AdPolicy) {
-
- /** Map a CustomerInfo to premium status and forward it to the Rust core. */
- private fun apply(info: CustomerInfo) {
- val active = info.entitlements[ENTITLEMENT_PREMIUM]?.isActive == true
- // OR-in the debug tester override so an async entitlement fetch can never clobber a
- // local unlock. In release this term is always false (see testerUnlockEnabled).
- core.setPremium(active || testerUnlockEnabled())
- }
-
- /**
- * INTERNAL DEV ONLY — force premium without a purchase, for local QA on a debug build.
- *
- * It still routes through the single source of truth (`core.setPremium` via [apply]),
- * so it adds no second premium flag. It is double-gated on `BuildConfig.DEBUG` *and* the
- * `TESTER_UNLOCK` build-config flag (true only in the debug build type), so it compiles
- * to a constant `false` in any build uploaded to Google Play — including the closed-test
- * track, which is a *release* build. Closed-test testers are unlocked instead via a
- * RevenueCat promotional grant or Google Play license testing.
- */
- private fun testerUnlockEnabled(): Boolean =
- BuildConfig.DEBUG && BuildConfig.TESTER_UNLOCK
-
- /**
- * Call once at startup. Performs an initial entitlement fetch and installs a listener so
- * any later change (a purchase completing, a restore, or a lapse) updates the core
- * immediately — no app restart needed.
- */
- fun bindEntitlement() {
- if (testerUnlockEnabled()) core.setPremium(true) // immediate local unlock (debug only)
- Purchases.sharedInstance.getCustomerInfoWith(
- onError = { /* offline / transient: core stays at its last known value */ },
- onSuccess = { info -> apply(info) },
- )
- Purchases.sharedInstance.updatedCustomerInfoListener =
- UpdatedCustomerInfoListener { info -> apply(info) }
- }
-
- /**
- * Purchase the premium (remove-ads) package from the current RevenueCat offering.
- * On success the listener above also fires, but we apply here too so the UI can react
- * synchronously in [onResult].
- */
- fun purchasePremium(
- activity: Activity,
- onResult: (premium: Boolean, error: PurchasesError?) -> Unit,
- ) {
- Purchases.sharedInstance.getOfferingsWith(
- onError = { error -> onResult(core.isPremium(), error) },
- onSuccess = { offerings ->
- // Prefer the LIFETIME (non-consumable "Full Version / Remove Ads") package
- // explicitly rather than assuming it is first — so adding a tier/subscription
- // to the offering later can't silently change what gets purchased.
- val pkg = offerings.current?.availablePackages
- ?.firstOrNull { it.packageType == PackageType.LIFETIME }
- ?: offerings.current?.availablePackages?.firstOrNull()
- if (pkg == null) {
- onResult(core.isPremium(), null) // misconfigured offering
- return@getOfferingsWith
- }
- Purchases.sharedInstance.purchaseWith(
- PurchaseParams.Builder(activity, pkg).build(),
- onError = { error, _ -> onResult(core.isPremium(), error) },
- // RevenueCat 8.x hands back a nullable StoreTransaction (null for a
- // restore / already-owned grant) — take it as `StoreTransaction?`.
- onSuccess = { _: StoreTransaction?, info: CustomerInfo ->
- apply(info)
- onResult(core.isPremium(), null)
- },
- )
- },
- )
- }
-
- /**
- * Restore prior purchases. Surface this behind a visible "Restore Purchases" control —
- * both Google and Apple expect freely available restore for non-consumable / lifetime
- * entitlements.
- */
- fun restorePurchases(onResult: (premium: Boolean, error: PurchasesError?) -> Unit) {
- // Use the RevenueCat 8.x KTX helper `restorePurchasesWith` (the base
- // `restorePurchases` takes a single ReceiveCustomerInfoCallback, not named lambdas).
- Purchases.sharedInstance.restorePurchasesWith(
- onError = { error -> onResult(core.isPremium(), error) },
- onSuccess = { info ->
- apply(info)
- onResult(core.isPremium(), null)
- },
- )
- }
-
- companion object {
- /**
- * The RevenueCat entitlement identifier configured in the RevenueCat dashboard. A
- * single entitlement (unlocked by a non-consumable "remove ads" / full-version
- * purchase) toggles every gate in the core.
- */
- const val ENTITLEMENT_PREMIUM = "premium"
- }
-}
diff --git a/android/app/src/play/java/com/doublegate/rustynes/monetization/RewardedGate.kt b/android/app/src/play/java/com/doublegate/rustynes/monetization/RewardedGate.kt
deleted file mode 100644
index 3c398ec0..00000000
--- a/android/app/src/play/java/com/doublegate/rustynes/monetization/RewardedGate.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * RewardedGate.kt — AppLovin MAX rewarded-ad gate (PLAY-FLAVOR ONLY, v2.0.3, ADR 0025).
- *
- * Adapted from `crates/rustynes-monetization/shells/android/RewardedGate.kt`. Counterpart
- * to AdGate.kt (interstitials); like it, this lives only in the `play` source set (it
- * imports `com.applovin.*`). It is the free-tier play-time extender: when a free user is
- * out of budget and taps "Watch ad for +time", show a rewarded ad, and on the network's
- * REWARD callback call the shared core's grantRewardedTime() — never on load, show, or
- * dismiss, so the grant maps exactly to a qualifying view.
- *
- * Cadence/cap policy lives in the core: gate the offer on core.canOfferRewarded() and
- * label it with core.rewardGrantsRemaining(); grantRewardedTime() enforces the per-session
- * grant cap and returns false (a no-op) once it is reached.
- */
-package com.doublegate.rustynes.monetization
-
-import android.app.Activity
-import com.applovin.mediation.MaxAd
-import com.applovin.mediation.MaxError
-import com.applovin.mediation.MaxReward
-import com.applovin.mediation.MaxRewardedAdListener
-import com.applovin.mediation.ads.MaxRewardedAd
-import com.doublegate.rustynes.BuildConfig
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-
-/**
- * Rewarded gate. The [onResume] callback resumes the paused emulator after a granted
- * reward (or an early bail — the caller's resume path always runs).
- */
-class RewardedGate(
- private val activity: Activity,
- private val core: AdPolicy,
- /** Called after a granted reward so the host can resume the paused emulator. */
- private val onResume: () -> Unit = {},
-) : MaxRewardedAdListener {
-
- private val rewarded: MaxRewardedAd =
- MaxRewardedAd.getInstance(BuildConfig.MAX_REWARDED_AD_UNIT_ID, activity).apply {
- setListener(this@RewardedGate)
- }
-
- /** Warm the cache so the "+time" tap plays instantly. Call early, and ~60-90 s before run-out. */
- fun preload() {
- if (!core.isPremium()) rewarded.loadAd()
- }
-
- /**
- * Present a rewarded ad if one is ready. The caller should only reach here when the
- * core says an offer is allowed (core.canOfferRewarded()); premium users never do.
- * Returns false if no ad was ready (caller should fall back to the Full Version prompt
- * or the offline-grace path, core.canGrantOfflineGrace()).
- */
- fun show(): Boolean {
- if (core.isPremium() || !core.canOfferRewarded()) return false
- return if (rewarded.isReady) {
- rewarded.showAd()
- true
- } else {
- rewarded.loadAd() // not cached yet; the caller handles the no-ad-available case
- false
- }
- }
-
- // --- MaxRewardedAdListener ------------------------------------------------------
-
- /**
- * The ONLY place a grant happens. Fired when the user has watched the ad for the
- * required duration. grantRewardedTime() adds +reward_play_ms and enforces the
- * per-session cap (returns false once hit); then resume the emulator.
- */
- override fun onUserRewarded(ad: MaxAd, reward: MaxReward) {
- core.grantRewardedTime()
- onResume()
- }
-
- override fun onAdLoaded(ad: MaxAd) { /* cached; shown explicitly via show() */ }
- override fun onAdDisplayed(ad: MaxAd) { /* no-op */ }
-
- override fun onAdHidden(ad: MaxAd) {
- // Reload for the next run-out. If onUserRewarded did NOT fire (user bailed early),
- // no grant was made — the caller's resume path still runs the paused emulator.
- if (!core.isPremium()) rewarded.loadAd()
- }
-
- override fun onAdClicked(ad: MaxAd) { /* no-op */ }
- override fun onAdLoadFailed(adUnitId: String, error: MaxError) { /* optional: backoff retry */ }
- override fun onAdDisplayFailed(ad: MaxAd, error: MaxError) {
- if (!core.isPremium()) rewarded.loadAd()
- }
-
- /**
- * Detach the listener so this gate (and the Activity it holds) is not retained by the
- * shared `MaxRewardedAd` singleton. Call from the owner's `onDestroy`.
- */
- fun destroy() {
- rewarded.setListener(null)
- }
-}
diff --git a/android/app/src/play/res/values-es/strings.xml b/android/app/src/play/res/values-es/strings.xml
deleted file mode 100644
index a202a4c5..00000000
--- a/android/app/src/play/res/values-es/strings.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Juego gratis: %1$s restante
- Sesión gratuita finalizada
- Se acabó tu tiempo de juego gratuito. Mira un breve anuncio para conseguir más tiempo, desbloquea la Versión completa para eliminar anuncios y límites, o continúa sin conexión.
- Ver anuncio para más tiempo
- Continuar sin conexión
- Obtener la Versión completa
-
diff --git a/android/app/src/play/res/values/strings.xml b/android/app/src/play/res/values/strings.xml
deleted file mode 100644
index 53b142e6..00000000
--- a/android/app/src/play/res/values/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- Free play: %1$s left
-
- Free session ended
- Your free play time is up. Watch a short ad for more time, unlock the Full Version to remove ads and limits, or continue offline.
- Watch ad for more time
- Continue offline
- Get the Full Version
-
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index a0a0f80a..56b8a296 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -25,16 +25,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
- // v2.0.3 "Harbor" (ADR 0025): AppLovin MAX (the `play`-flavor ad mediation SDK,
- // AdGate / RewardedGate) is published on AppLovin's own Maven repository, not
- // Maven Central. `content { includeGroup(...) }` scopes it to the AppLovin group
- // only, so every other artifact still resolves from google()/mavenCentral() and
- // the clean `foss` variant — which never references `com.applovin.*` — pulls
- // nothing from here. RevenueCat (com.revenuecat.purchases) is on Maven Central.
- maven {
- url = uri("https://artifacts.applovin.com/android")
- content { includeGroup("com.applovin") }
- }
}
}
diff --git a/crates/rustynes-monetization/Cargo.toml b/crates/rustynes-monetization/Cargo.toml
deleted file mode 100644
index b59df8e6..00000000
--- a/crates/rustynes-monetization/Cargo.toml
+++ /dev/null
@@ -1,53 +0,0 @@
-# rustynes-monetization — the RustyNES mobile monetization bridge.
-#
-# A standalone UniFFI crate carrying ONLY the cross-platform entitlement + ad-pacing
-# policy (`AdPolicy`) the Android and iOS shells share. It is intentionally NOT part of
-# the emulator crate graph (`rustynes-cpu/ppu/apu/mappers/core`) and never feeds state
-# into the deterministic core — monetization is strictly a host/frontend concern (see
-# `src/monetization.rs` "Determinism boundary").
-#
-# Mobile-only by intent: it pulls UniFFI and is consumed by the Android/iOS app shells,
-# not by the desktop/wasm frontend. It still builds + tests on the host (cheap), so the
-# workspace gates (fmt / clippy / doc / test) cover it.
-#
-# Harvested + customized (2026-06-23) from the standalone `rustynes-monetization/`
-# scaffold: aligned to the workspace toolchain (edition 2024 / Rust 1.96) and bumped to
-# UniFFI 0.32 to match `rustynes-mobile` (one UniFFI version across the workspace).
-#
-# Built to three artifact kinds:
-# * cdylib -> the .so the Android app loads via UniFFI's Kotlin loader
-# * staticlib -> the .a linked into the iOS xcframework
-# * lib -> the rlib `cargo test` + the in-crate uniffi-bindgen binary use
-
-[package]
-name = "rustynes-monetization"
-version.workspace = true
-edition.workspace = true
-rust-version.workspace = true
-license.workspace = true
-authors.workspace = true
-repository.workspace = true
-description = "RustyNES mobile monetization bridge: cross-platform entitlement & ad policy."
-
-[lib]
-name = "rustynes_monetization"
-crate-type = ["cdylib", "staticlib", "lib"]
-
-# The uniffi-bindgen CLI lives in-crate so it runs on stable Rust (the standalone binary
-# needs nightly). Invoke with `--features=cli` — see docs/monetization/build-and-bindings.md.
-# `doc = false`: this is a build-tool binary, not documented API. It also shares the name
-# `uniffi-bindgen` with `rustynes-mobile`'s bindgen bin, so without this `cargo doc
-# --workspace` hits an output-filename collision between the two identically-named bins.
-[[bin]]
-name = "uniffi-bindgen"
-path = "uniffi-bindgen.rs"
-doc = false
-
-[dependencies]
-# Pinned to 0.32 to match `rustynes-mobile` so the workspace resolves a single UniFFI.
-uniffi = { version = "0.32" }
-
-[features]
-# Enables the bindgen CLI entry point only when requested, keeping the default build
-# (and the shipped library) free of CLI-only dependencies.
-cli = ["uniffi/cli"]
diff --git a/crates/rustynes-monetization/shells/README.md b/crates/rustynes-monetization/shells/README.md
deleted file mode 100644
index 2bde9d22..00000000
--- a/crates/rustynes-monetization/shells/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# `rustynes-monetization` platform shells (staged reference glue)
-
-These are the **Android (Kotlin)** and **iOS (Swift)** app shells that consume the
-`rustynes-monetization` Rust core (`AdPolicy`) through its UniFFI bindings. They are the
-counterpart implementations that keep ad cadence + the play-time gate + the premium-feature
-set **identical across both platforms** (all policy lives in the Rust core; the shells only
-plumb the SDKs).
-
-## Status: staged, not yet wired into the live build
-
-They are **reference glue**, deliberately kept out of the compiled app for now:
-
-- They import the proprietary **AppLovin MAX** (`com.applovin.*` / `AppLovinSDK`) and
- **RevenueCat** (`com.revenuecat.purchases.*` / `RevenueCat`) SDKs, which are **not** added
- to the live Gradle / SPM builds yet.
-- They reference `BuildConfig` fields (`APPLOVIN_SDK_KEY`, `REVENUECAT_API_KEY`,
- `MAX_INTERSTITIAL_AD_UNIT_ID`, `TESTER_UNLOCK`) and an Info.plist `RUSTYNES_TESTER_UNLOCK`
- that are added at wiring time.
-- Per the **2026-06-23 mobile-launch replan**, both app-store launches (and therefore the
- freemium / ad layer) are **deferred to v2.1.0** — see
- [`../../../to-dos/plans/v2.0.x-mobile-finalization-plan.md`](../../../to-dos/plans/v2.0.x-mobile-finalization-plan.md).
- The freemium layer ships **default-off behind `PLAY_BUILD`** and stays dormant in the
- v1.8.x sideload builds until then.
-
-So nothing here compiles into the current sideload app; they wait for the v2.1.0 wiring.
-
-## What's here
-
-| File | Role |
-|---|---|
-| `android/RustyNesApp.kt` | `Application` entry point: build `AdPolicy`, init AppLovin MAX, configure RevenueCat |
-| `android/Billing.kt` | RevenueCat wrapper → `AdPolicy.setPremium` (the single premium source of truth) + a debug tester override |
-| `android/AdGate.kt` | AppLovin MAX interstitial lifecycle; defers every cadence decision to the core |
-| `android/build.gradle.kts` | Reference Gradle dependency + `BuildConfig` field snippets to merge into `android/app/build.gradle.kts` |
-| `android/AndroidManifest.xml` | Reference manifest entries (AdMob app id, network permissions) |
-| `ios/RustyNesApp.swift` | iOS coordinator mirroring the Android entry point |
-| `ios/Billing.swift` | RevenueCat wrapper (iOS) |
-| `ios/AdGate.swift` | AppLovin MAX interstitial gate (iOS) |
-| `ios/Package.swift` | Reference SPM manifest for the RevenueCat + AppLovin dependencies |
-
-The Android shells are customized to the **`com.doublegate.rustynes.monetization`** package
-and import the UniFFI-generated bindings from `com.doublegate.rustynes.monetization.ffi`
-(matching `../uniffi.toml`). `BuildConfig` resolves from the app module
-(`com.doublegate.rustynes.BuildConfig`).
-
-## Wiring checklist (v2.1.0 launch)
-
-1. Add the SDK dependencies (AppLovin MAX 13+, RevenueCat / `purchases` 8+) and the
- `BuildConfig` fields / `buildConfigField`s from `android/build.gradle.kts` into
- `android/app/build.gradle.kts` behind the **`play`** product flavor / `PLAY_BUILD` flag.
-2. Copy the Kotlin shells into `android/app/src//java/com/doublegate/rustynes/monetization/`
- and adapt `RustyNesApp` wiring to the real `Application` / `MainActivity` (these shells are a
- self-contained skeleton, not a drop-in over the existing Compose app).
-3. Regenerate the UniFFI Kotlin bindings from the crate (see
- [`../../../docs/monetization/build-and-bindings.md`](../../../docs/monetization/build-and-bindings.md)).
-4. Gate all six `PremiumFeature`s on `feature_enabled(...)` — `SaveStates` /
- `SaveOnExitResume` / `BatterySaves` / `FastForward` / `Shaders` / `Cheats`; keep
- in-session rewind free (per `docs/monetization/rustynes-integration.md` §4).
-5. iOS: mirror with the SPM manifest + the Swift shells once the iOS app exists (v2.0.5+).
-6. Complete the store / dashboard setup in
- [`../../../docs/monetization/platform-setup-runbook.md`](../../../docs/monetization/platform-setup-runbook.md)
- and the compliance items in `docs/monetization/implementation-brief.md`.
diff --git a/crates/rustynes-monetization/shells/android/AdGate.kt b/crates/rustynes-monetization/shells/android/AdGate.kt
deleted file mode 100644
index 0ee4577d..00000000
--- a/crates/rustynes-monetization/shells/android/AdGate.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * AdGate.kt — AppLovin MAX interstitial gate for Android.
- *
- * The gate owns the MAX interstitial lifecycle (preload → show → reload) but defers
- * EVERY policy question to the shared Rust core:
- * • shouldShowInterstitial(nowMs) decides if a break point is eligible
- * • notifyInterstitialShown(nowMs) arms the cooldown, only after a real display
- *
- * Because the identical core drives the iOS gate, the two platforms cannot diverge on
- * cadence. The gate also no-ops automatically for premium users, since the core's
- * shouldShowInterstitial returns false whenever premium is set.
- *
- * Typical wiring from an Activity:
- * private val gate by lazy { AdGate(this, (application as RustyNesApp).core) }
- * override fun onCreate(...) { gate.preload() }
- * // at a natural break (ROM loaded, returned to menu, save-state taken):
- * gate.maybeShowInterstitial()
- */
-package com.doublegate.rustynes.monetization
-
-import com.doublegate.rustynes.BuildConfig
-
-import android.app.Activity
-import android.os.SystemClock
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.applovin.mediation.MaxAd
-import com.applovin.mediation.MaxAdListener
-import com.applovin.mediation.MaxError
-import com.applovin.mediation.ads.MaxInterstitialAd
-
-class AdGate(
- private val activity: Activity,
- private val core: AdPolicy,
-) : MaxAdListener {
-
- private val interstitial: MaxInterstitialAd =
- MaxInterstitialAd(BuildConfig.MAX_INTERSTITIAL_AD_UNIT_ID, activity).apply {
- setListener(this@AdGate)
- }
-
- /** Monotonic ms, matching the clock model the Rust core was constructed with. */
- private fun nowMs(): ULong = SystemClock.elapsedRealtime().toULong()
-
- /** Warm the cache so a later show() is instant. Safe to call once after init. */
- fun preload() {
- if (!core.isPremium()) interstitial.loadAd()
- }
-
- /**
- * Show an interstitial iff the core allows it right now. Premium users, the launch
- * grace window, and the inter-ad cooldown are all handled inside the core, so the
- * caller can invoke this freely at any natural break point.
- */
- fun maybeShowInterstitial() {
- if (!core.shouldShowInterstitial(nowMs())) return
- if (interstitial.isReady) {
- interstitial.showAd()
- } else {
- interstitial.loadAd() // not cached yet; show on the next break point
- }
- }
-
- // --- MaxAdListener -------------------------------------------------------------
-
- override fun onAdLoaded(ad: MaxAd) {
- // Loaded and cached. We deliberately do NOT auto-show here; showing is gated by
- // the core at an explicit break point via maybeShowInterstitial().
- }
-
- override fun onAdDisplayed(ad: MaxAd) {
- // The ad is actually on screen — arm the cooldown now (not at decision time),
- // so a failed load never burns the interval.
- core.notifyInterstitialShown(nowMs())
- }
-
- override fun onAdHidden(ad: MaxAd) {
- // User dismissed the ad; immediately reload for the next eligible break.
- if (!core.isPremium()) interstitial.loadAd()
- }
-
- override fun onAdClicked(ad: MaxAd) { /* no-op */ }
-
- override fun onAdLoadFailed(adUnitId: String, error: MaxError) {
- // Optional: implement exponential backoff before retrying loadAd().
- }
-
- override fun onAdDisplayFailed(ad: MaxAd, error: MaxError) {
- // Display failed; reload so the next break point can try again.
- if (!core.isPremium()) interstitial.loadAd()
- }
-}
diff --git a/crates/rustynes-monetization/shells/android/Billing.kt b/crates/rustynes-monetization/shells/android/Billing.kt
deleted file mode 100644
index ab818f0d..00000000
--- a/crates/rustynes-monetization/shells/android/Billing.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * Billing.kt — RevenueCat wrapper for Android.
- *
- * This is the *only* place that knows about the store. It translates RevenueCat's
- * `CustomerInfo` into a single boolean (premium yes/no) and pushes it into the shared
- * Rust core via `AdPolicy.setPremium`. Everything else in the app — including whether
- * ads show — derives from that one flag, so there is no second source of truth.
- *
- * Flows implemented:
- * • bindEntitlement() — initial fetch + live listener (purchase / restore / expiry)
- * • purchasePremium() — buy the "remove ads" / full-version package
- * • restorePurchases() — required by both stores; re-activates a prior purchase
- *
- * The entitlement id is RustyNesApp.ENTITLEMENT_PREMIUM ("premium").
- */
-package com.doublegate.rustynes.monetization
-
-import com.doublegate.rustynes.BuildConfig
-
-import android.app.Activity
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.revenuecat.purchases.CustomerInfo
-import com.revenuecat.purchases.PackageType
-import com.revenuecat.purchases.Purchases
-import com.revenuecat.purchases.PurchasesError
-import com.revenuecat.purchases.UpdatedCustomerInfoListener
-import com.revenuecat.purchases.getCustomerInfoWith
-import com.revenuecat.purchases.getOfferingsWith
-import com.revenuecat.purchases.models.StoreTransaction
-import com.revenuecat.purchases.purchaseWith
-import com.revenuecat.purchases.PurchaseParams
-
-class Billing(private val core: AdPolicy) {
-
- /** Map a CustomerInfo to premium status and forward it to the Rust core. */
- private fun apply(info: CustomerInfo) {
- val active = info.entitlements[RustyNesApp.ENTITLEMENT_PREMIUM]?.isActive == true
- // OR-in the debug tester override so an async entitlement fetch can never clobber a
- // local unlock. In release this term is always false (see testerUnlockEnabled).
- core.setPremium(active || testerUnlockEnabled())
- }
-
- /**
- * INTERNAL DEV ONLY — force premium without a purchase, for local QA on a debug build.
- *
- * It still routes through the single source of truth (`core.setPremium` via [apply]),
- * so it adds no second premium flag. It is double-gated on `BuildConfig.DEBUG` *and* the
- * `TESTER_UNLOCK` build-config flag (true only in the debug build type), so it compiles
- * to a constant `false` in any build uploaded to Google Play — including the closed-test
- * track, which is a *release* build. Closed-test testers are unlocked instead via a
- * RevenueCat promotional grant or Google Play license testing (runbook §5a, brief §9).
- */
- private fun testerUnlockEnabled(): Boolean =
- BuildConfig.DEBUG && BuildConfig.TESTER_UNLOCK
-
- /**
- * Call once at startup (RustyNesApp.onCreate). Performs an initial entitlement
- * fetch and installs a listener so any later change (a purchase completing, a
- * restore, or a lapse) updates the core immediately — no app restart needed.
- */
- fun bindEntitlement() {
- if (testerUnlockEnabled()) core.setPremium(true) // immediate local unlock (debug only)
- Purchases.sharedInstance.getCustomerInfoWith(
- onError = { /* offline / transient: core stays at its last known value */ },
- onSuccess = { info -> apply(info) }
- )
- Purchases.sharedInstance.updatedCustomerInfoListener =
- UpdatedCustomerInfoListener { info -> apply(info) }
- }
-
- /**
- * Purchase the premium (remove-ads) package from the current RevenueCat offering.
- * On success the listener above also fires, but we apply here too so the UI can
- * react synchronously in [onResult].
- *
- * @param activity the foreground Activity required to launch the billing dialog.
- */
- fun purchasePremium(
- activity: Activity,
- onResult: (premium: Boolean, error: PurchasesError?) -> Unit
- ) {
- Purchases.sharedInstance.getOfferingsWith(
- onError = { error -> onResult(core.isPremium(), error) },
- onSuccess = { offerings ->
- // Prefer the LIFETIME (non-consumable "Full Version / Remove Ads") package
- // explicitly rather than assuming it is first — so adding a tier/subscription
- // to the offering later can't silently change what gets purchased.
- val pkg = offerings.current?.availablePackages
- ?.firstOrNull { it.packageType == PackageType.LIFETIME }
- ?: offerings.current?.availablePackages?.firstOrNull()
- if (pkg == null) {
- onResult(core.isPremium(), null) // misconfigured offering
- return@getOfferingsWith
- }
- Purchases.sharedInstance.purchaseWith(
- PurchaseParams.Builder(activity, pkg).build(),
- onError = { error, _ -> onResult(core.isPremium(), error) },
- onSuccess = { _: StoreTransaction, info: CustomerInfo ->
- apply(info)
- onResult(core.isPremium(), null)
- }
- )
- }
- )
- }
-
- /**
- * Restore prior purchases. Surface this behind a visible "Restore Purchases"
- * control — both Google and Apple expect freely available restore for
- * non-consumable / lifetime entitlements.
- */
- fun restorePurchases(onResult: (premium: Boolean, error: PurchasesError?) -> Unit) {
- Purchases.sharedInstance.restorePurchases(
- onError = { error -> onResult(core.isPremium(), error) },
- onSuccess = { info ->
- apply(info)
- onResult(core.isPremium(), null)
- }
- )
- }
-}
diff --git a/crates/rustynes-monetization/shells/android/RewardedGate.kt b/crates/rustynes-monetization/shells/android/RewardedGate.kt
deleted file mode 100644
index b1c026dd..00000000
--- a/crates/rustynes-monetization/shells/android/RewardedGate.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * RewardedGate.kt — AppLovin MAX rewarded-ad gate for Android (the free-tier engine).
- *
- * Counterpart to AdGate.kt (interstitials). This owns the MAX rewarded lifecycle
- * (preload -> show -> reload) and is what extends the free-tier play budget: when the
- * user is out of time and taps "Watch ad for +11 min", show a rewarded ad, and on the
- * network's REWARD callback call the shared core's grantRewardedTime() — never on load,
- * show, or dismiss, so the grant maps exactly to a qualifying view.
- *
- * Cadence/cap policy lives in the core: gate the offer on core.canOfferRewarded() and
- * label it with core.rewardGrantsRemaining(); grantRewardedTime() enforces the 2-grant
- * cap and returns false (a no-op) once it is reached.
- *
- * Typical wiring from the run-out prompt:
- * private val rewarded by lazy { RewardedGate(this, core) { resumeEmulator() } }
- * override fun onCreate(...) { rewarded.preload() }
- * // when the user taps "Watch ad for +11 min" (only shown if core.canOfferRewarded()):
- * rewarded.show()
- */
-package com.doublegate.rustynes.monetization
-
-import android.app.Activity
-
-import com.doublegate.rustynes.BuildConfig
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.applovin.mediation.MaxAd
-import com.applovin.mediation.MaxError
-import com.applovin.mediation.MaxReward
-import com.applovin.mediation.MaxRewardedAdListener
-import com.applovin.mediation.ads.MaxRewardedAd
-
-class RewardedGate(
- private val activity: Activity,
- private val core: AdPolicy,
- /** Called after a granted reward so the host can resume the paused emulator. */
- private val onResume: () -> Unit = {},
-) : MaxRewardedAdListener {
-
- private val rewarded: MaxRewardedAd =
- MaxRewardedAd.getInstance(BuildConfig.MAX_REWARDED_AD_UNIT_ID, activity).apply {
- setListener(this@RewardedGate)
- }
-
- /** Warm the cache so the "+11 min" tap plays instantly. Call early, and ~60-90 s before run-out. */
- fun preload() {
- if (!core.isPremium()) rewarded.loadAd()
- }
-
- /**
- * Present a rewarded ad if one is ready. The caller should only reach here when the
- * core says an offer is allowed (core.canOfferRewarded()); premium users never do.
- * Returns false if no ad was ready (caller should fall back to the Full Version prompt
- * or the offline-grace path, core.canGrantOfflineGrace()).
- */
- fun show(): Boolean {
- if (core.isPremium() || !core.canOfferRewarded()) return false
- return if (rewarded.isReady) {
- rewarded.showAd()
- true
- } else {
- rewarded.loadAd() // not cached yet; the caller handles the no-ad-available case
- false
- }
- }
-
- // --- MaxRewardedAdListener ------------------------------------------------------
-
- /**
- * The ONLY place a grant happens. Fired when the user has watched the ad for the
- * required duration. grantRewardedTime() adds +reward_play_ms and enforces the
- * per-session cap (returns false once hit); then resume the emulator.
- */
- override fun onUserRewarded(ad: MaxAd, reward: MaxReward) {
- core.grantRewardedTime()
- onResume()
- }
-
- override fun onAdLoaded(ad: MaxAd) { /* cached; shown explicitly via show() */ }
- override fun onAdDisplayed(ad: MaxAd) { /* no-op */ }
-
- override fun onAdHidden(ad: MaxAd) {
- // Reload for the next run-out. If onUserRewarded did NOT fire (user bailed early),
- // no grant was made — the caller's resume path still runs the paused emulator.
- if (!core.isPremium()) rewarded.loadAd()
- }
-
- override fun onAdClicked(ad: MaxAd) { /* no-op */ }
- override fun onAdLoadFailed(adUnitId: String, error: MaxError) { /* optional: backoff retry */ }
- override fun onAdDisplayFailed(ad: MaxAd, error: MaxError) {
- if (!core.isPremium()) rewarded.loadAd()
- }
-}
diff --git a/crates/rustynes-monetization/shells/android/RustyNesApp.kt b/crates/rustynes-monetization/shells/android/RustyNesApp.kt
deleted file mode 100644
index 9fa1231a..00000000
--- a/crates/rustynes-monetization/shells/android/RustyNesApp.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * RustyNesApp.kt — Android Application entry point.
- *
- * Responsibilities:
- * 1. Construct the single shared `AdPolicy` from the Rust core (UniFFI binding).
- * 2. Initialize the AppLovin MAX SDK as early as possible (per AppLovin guidance,
- * this maximizes ad pre-caching time and improves fill).
- * 3. Configure RevenueCat and immediately bind the premium entitlement into the
- * core so the very first ad decision already reflects the user's paid status.
- *
- * The objects created here (core, billing, adGate) are process-wide singletons,
- * exposed via the Application instance so Activities can reach them. In a larger app
- * prefer Hilt/Koin; kept explicit here for a self-contained skeleton.
- *
- * Required Gradle dependencies (see build.gradle.kts):
- * com.applovin:applovin-sdk — MAX mediation SDK (init API used below)
- * com.revenuecat.purchases:purchases — RevenueCat (entitlement source of truth)
- * Plus the UniFFI-generated `com.doublegate.rustynes.monetization.ffi` package and the native librustynes_monetization
- * .so files under src/main/jniLibs (produced by cargo-ndk — see README).
- */
-package com.doublegate.rustynes.monetization
-
-import com.doublegate.rustynes.BuildConfig
-
-import android.app.Application
-import android.os.SystemClock
-import com.doublegate.rustynes.monetization.ffi.AdPolicy
-import com.doublegate.rustynes.monetization.ffi.defaultAdConfig
-import com.applovin.sdk.AppLovinMediationProvider
-import com.applovin.sdk.AppLovinSdk
-import com.applovin.sdk.AppLovinSdkInitializationConfiguration
-import com.revenuecat.purchases.LogLevel
-import com.revenuecat.purchases.Purchases
-import com.revenuecat.purchases.PurchasesConfiguration
-
-class RustyNesApp : Application() {
-
- /** Shared monetization core. `defaultAdConfig()` and `AdPolicy(...)` are UniFFI bindings. */
- lateinit var core: AdPolicy
- private set
-
- /** RevenueCat wrapper that keeps [core]'s premium flag current. */
- lateinit var billing: Billing
- private set
-
- override fun onCreate() {
- super.onCreate()
-
- // (1) Build the policy core. SystemClock.elapsedRealtime() is the monotonic
- // millisecond clock the Rust core expects; the core stores this as the launch
- // anchor for its grace window. The Rust `u64` maps to Kotlin `ULong`.
- core = AdPolicy(defaultAdConfig(), SystemClock.elapsedRealtime().toULong())
-
- // The ad + billing SDKs are initialized ONLY in the Play build. Sideload /
- // F-Droid / GitHub-Releases builds (PLAY_BUILD=false) stay full-featured and
- // ad-free, so the AppLovin (tracking) + RevenueCat SDKs never even initialize
- // there — a privacy + performance win and the project's stated sideload promise.
- if (BuildConfig.PLAY_BUILD) {
- // (2) Initialize AppLovin MAX with the current (config-builder) init API.
- // Do this before loading any ad. The SDK key comes from the AppLovin dashboard
- // (Account > General > Keys). The MAX mediation provider must be set explicitly.
- val initConfig = AppLovinSdkInitializationConfiguration
- .builder(BuildConfig.APPLOVIN_SDK_KEY, this)
- .setMediationProvider(AppLovinMediationProvider.MAX)
- .build()
- AppLovinSdk.getInstance(this).initialize(initConfig) { _ ->
- // SDK ready — Activities may now preload/show interstitials via AdGate.
- }
-
- // (3) Configure RevenueCat and wire the entitlement → core. Use the Android
- // (Google) public API key. Premium status is pushed into the core both now
- // (initial fetch) and on every future change (purchase / restore / expiry).
- Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
- Purchases.configure(
- PurchasesConfiguration.Builder(this, BuildConfig.REVENUECAT_API_KEY).build()
- )
- billing = Billing(core)
- billing.bindEntitlement()
- }
- }
-
- companion object {
- /**
- * The RevenueCat entitlement identifier configured in the RevenueCat
- * dashboard. A single entitlement (e.g. unlocked by a non-consumable
- * "remove ads" / full-version purchase) toggles every gate in the core.
- */
- const val ENTITLEMENT_PREMIUM = "premium"
- }
-}
diff --git a/crates/rustynes-monetization/shells/android/build.gradle.kts b/crates/rustynes-monetization/shells/android/build.gradle.kts
deleted file mode 100644
index 8a5a8eb9..00000000
--- a/crates/rustynes-monetization/shells/android/build.gradle.kts
+++ /dev/null
@@ -1,75 +0,0 @@
-// build.gradle.kts (app module) — excerpt showing the monetization-relevant wiring.
-//
-// This is NOT a complete Android Gradle file; it highlights only the pieces this
-// skeleton needs: the SDK dependencies, the BuildConfig fields the Kotlin reads, and
-// the jniLibs source set where cargo-ndk drops librustynes_monetization.so.
-
-plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
-}
-
-android {
- namespace = "app.rustynes"
- compileSdk = 35 // Play Billing 9 / current target requirement
-
- defaultConfig {
- applicationId = "app.rustynes"
- minSdk = 24 // AppLovin MAX 13 supports down to 21; 24 is a safe floor
- targetSdk = 35
- versionCode = 1
- versionName = "1.0"
-
- // Values consumed by RustyNesApp.kt / AdGate.kt via BuildConfig.*.
- // Keep real keys out of source control — inject from gradle.properties or CI.
- buildConfigField("String", "APPLOVIN_SDK_KEY", "\"${providers.gradleProperty("applovinSdkKey").orNull ?: ""}\"")
- buildConfigField("String", "REVENUECAT_API_KEY", "\"${providers.gradleProperty("revenueCatGoogleKey").orNull ?: ""}\"")
- buildConfigField("String", "MAX_INTERSTITIAL_AD_UNIT_ID", "\"${providers.gradleProperty("maxInterstitialAdUnitId").orNull ?: ""}\"")
- }
-
- buildFeatures {
- buildConfig = true
- }
-
- buildTypes {
- debug {
- // Local-dev tester unlock: lets Billing.kt force premium WITHOUT a purchase on a
- // debug build, for QA. NEVER reaches Play — release sets this false, and the
- // closed-test track is a release build, so its testers must be unlocked via a
- // RevenueCat promotional grant or Google Play license testing (runbook §5a).
- buildConfigField("boolean", "TESTER_UNLOCK", "true")
- }
- release {
- buildConfigField("boolean", "TESTER_UNLOCK", "false")
- // If you enable R8/minify, add keep rules for the JNA + generated FFI classes.
- }
- }
-
- // cargo-ndk writes the per-ABI .so files here (see README build step).
- sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs")
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
- kotlinOptions { jvmTarget = "17" }
-}
-
-dependencies {
- // --- Monetization SDKs -----------------------------------------------------------
- // Pin to the newest patch before release; versions below are recent at time of writing.
- implementation("com.applovin:applovin-sdk:13.0.1") // MAX mediation
- implementation("com.revenuecat.purchases:purchases:8.10.0") // RevenueCat (Google)
-
- // AppLovin's Google bidding/AdMob adapter (very common in a MAX waterfall) requires
- // the AdMob app id in the manifest (see AndroidManifest.xml). Add mediation adapters
- // here as you enable each network in the MAX dashboard, e.g.:
- // implementation("com.applovin.mediation:google-adapter:x.y.z")
-
- // --- UniFFI runtime --------------------------------------------------------------
- // The generated app/rustynes/ffi/rustynes_monetization.kt uses JNA to load the native .so.
- implementation("net.java.dev.jna:jna:5.14.0@aar")
-
- implementation("androidx.core:core-ktx:1.13.1")
- implementation("androidx.appcompat:appcompat:1.7.0")
-}
diff --git a/crates/rustynes-monetization/shells/ios/AdGate.swift b/crates/rustynes-monetization/shells/ios/AdGate.swift
deleted file mode 100644
index 188b6a2a..00000000
--- a/crates/rustynes-monetization/shells/ios/AdGate.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-//
-// AdGate.swift — AppLovin MAX interstitial gate for iOS.
-//
-// Direct counterpart of Android's AdGate.kt. Owns the MAX interstitial lifecycle
-// (preload → show → reload) and defers every policy decision to the shared Rust core
-// (shouldShowInterstitial / notifyInterstitialShown). Identical core ⇒ identical
-// cadence on both platforms, and an automatic no-op for premium users.
-//
-// Usage:
-// Monetization.shared.adGate.preload() // once, after SDK init
-// Monetization.shared.adGate.maybeShowInterstitial() // at a natural break point
-//
-
-import Foundation
-import AppLovinSDK
-import RustyNesMonetization
-
-final class AdGate: NSObject, MAAdDelegate {
- private let core: AdPolicy
- private let interstitial: MAInterstitialAd
-
- init(core: AdPolicy) {
- self.core = core
- self.interstitial = MAInterstitialAd(adUnitIdentifier: Config.maxInterstitialAdUnitId)
- super.init()
- self.interstitial.delegate = self
- }
-
- /// Monotonic ms INCLUDING deep sleep, matching Android's SystemClock.elapsedRealtime()
- /// (the clock the Rust core was constructed with). `mach_continuous_time()` keeps
- /// ticking while the device sleeps — DispatchTime.uptimeNanoseconds pauses, which would
- /// diverge the ad cooldown / pacing from Android.
- private func nowMs() -> UInt64 {
- var info = mach_timebase_info()
- mach_timebase_info(&info)
- let nanos = mach_continuous_time() * UInt64(info.numer) / UInt64(info.denom)
- return nanos / 1_000_000
- }
-
- /// Warm the cache so a later show() is instant.
- func preload() {
- if !core.isPremium() { interstitial.load() }
- }
-
- /// Show an interstitial iff the core allows it now. Premium status, launch grace,
- /// and the inter-ad cooldown are all enforced inside the core.
- func maybeShowInterstitial() {
- guard core.shouldShowInterstitial(nowMs: nowMs()) else { return }
- if interstitial.isReady {
- interstitial.show()
- } else {
- interstitial.load() // not cached yet; show on the next break point
- }
- }
-
- // MARK: - MAAdDelegate
-
- func didLoad(_ ad: MAAd) {
- // Cached. We do not auto-show; showing is gated by the core via maybeShowInterstitial().
- }
-
- func didDisplay(_ ad: MAAd) {
- // On screen now — arm the cooldown (not at decision time) so a failed load
- // never consumes the interval.
- core.notifyInterstitialShown(nowMs: nowMs())
- }
-
- func didHide(_ ad: MAAd) {
- if !core.isPremium() { interstitial.load() } // reload for the next break
- }
-
- func didClick(_ ad: MAAd) { /* no-op */ }
-
- func didFailToLoadAd(forAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
- // Optional: exponential backoff before retrying interstitial.load().
- }
-
- func didFail(toDisplay ad: MAAd, withError error: MAError) {
- if !core.isPremium() { interstitial.load() }
- }
-}
diff --git a/crates/rustynes-monetization/shells/ios/Billing.swift b/crates/rustynes-monetization/shells/ios/Billing.swift
deleted file mode 100644
index 5d06e451..00000000
--- a/crates/rustynes-monetization/shells/ios/Billing.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-//
-// Billing.swift — RevenueCat wrapper for iOS.
-//
-// The iOS counterpart of Android's Billing.kt, with identical responsibilities and
-// the same single-source-of-truth discipline: translate RevenueCat `CustomerInfo`
-// into one premium boolean and push it into the shared Rust core. Conforms to
-// `PurchasesDelegate` so entitlement changes propagate live.
-//
-// Flows: bindEntitlement (initial + live), purchasePremium, restorePurchases.
-//
-
-import Foundation
-import RevenueCat
-import RustyNesMonetization
-
-final class Billing: NSObject, PurchasesDelegate {
- private let core: AdPolicy
-
- init(core: AdPolicy) {
- self.core = core
- super.init()
- }
-
- /// Map a CustomerInfo to premium status and forward it to the Rust core.
- private func apply(_ info: CustomerInfo?) {
- let active = info?.entitlements[Config.entitlementPremium]?.isActive == true
- // OR-in the debug tester override so an async fetch can never clobber a local
- // unlock. In release this term is always false (see testerUnlockEnabled).
- core.setPremium(premium: active || testerUnlockEnabled())
- }
-
- /// INTERNAL DEV ONLY — force premium without a purchase, for local QA on a debug build.
- ///
- /// Still routes through the single source of truth (`core.setPremium` via `apply`), so it
- /// adds no second premium flag. Gated on `#if DEBUG` *and* an Info.plist boolean
- /// `RUSTYNES_TESTER_UNLOCK` (default false), so it is inert in any App Store / TestFlight
- /// build. TestFlight testers are unlocked instead via a RevenueCat promotional grant or an
- /// App Store **sandbox** purchase (runbook §5a, brief §9).
- private func testerUnlockEnabled() -> Bool {
- #if DEBUG
- return (Bundle.main.object(forInfoDictionaryKey: "RUSTYNES_TESTER_UNLOCK") as? Bool) ?? false
- #else
- return false
- #endif
- }
-
- /// Call once at startup. Sets the delegate (live updates) and does an initial fetch.
- func bindEntitlement() {
- if testerUnlockEnabled() { core.setPremium(premium: true) } // immediate (debug only)
- Purchases.shared.delegate = self
- Purchases.shared.getCustomerInfo { [weak self] info, _ in
- self?.apply(info)
- }
- }
-
- /// PurchasesDelegate — fires whenever RevenueCat receives updated customer info
- /// (purchase, restore, renewal, expiry). Keeps the core's premium flag current.
- func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) {
- apply(customerInfo)
- }
-
- /// Purchase the premium (remove-ads) package from the current offering.
- func purchasePremium(completion: @escaping (_ premium: Bool, _ error: Error?) -> Void) {
- Purchases.shared.getOfferings { [weak self] offerings, error in
- guard let self else { return }
- // Prefer the LIFETIME (non-consumable "Full Version / Remove Ads") package
- // explicitly rather than assuming it is first — so adding a tier/subscription
- // to the offering later can't silently change what gets purchased.
- let package = offerings?.current?.availablePackages.first { $0.packageType == .lifetime }
- ?? offerings?.current?.availablePackages.first
- guard let package else {
- completion(self.core.isPremium(), error) // misconfigured offering
- return
- }
- Purchases.shared.purchase(package: package) { _, customerInfo, error, userCancelled in
- if userCancelled {
- completion(self.core.isPremium(), nil)
- return
- }
- self.apply(customerInfo)
- completion(self.core.isPremium(), error)
- }
- }
- }
-
- /// Restore prior purchases. Surface behind a visible "Restore Purchases" control —
- /// Apple requires freely available restore for non-consumable entitlements.
- func restorePurchases(completion: @escaping (_ premium: Bool, _ error: Error?) -> Void) {
- Purchases.shared.restorePurchases { [weak self] customerInfo, error in
- guard let self else { return }
- self.apply(customerInfo)
- completion(self.core.isPremium(), error)
- }
- }
-}
diff --git a/crates/rustynes-monetization/shells/ios/Package.swift b/crates/rustynes-monetization/shells/ios/Package.swift
deleted file mode 100644
index 8d9d3c68..00000000
--- a/crates/rustynes-monetization/shells/ios/Package.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-// swift-tools-version:5.9
-//
-// Package.swift — SwiftPM wiring for the iOS app's dependencies.
-//
-// Three dependencies are declared:
-// • RustyNesMonetization — the Rust core, packaged as a Swift package by `cargo swift`
-// (see README). It contains the generated RustyNesMonetization.swift plus
-// the librustynes_monetization xcframework as a binaryTarget.
-// • RevenueCat — purchases-ios SDK (entitlement source of truth).
-// • AppLovinSDK — MAX mediation SDK.
-//
-// In practice many teams add RevenueCat and AppLovinSDK through Xcode's SPM UI on the
-// app target and keep RustyNesMonetization as a local package. This manifest shows the
-// all-SPM arrangement for a self-contained reference.
-//
-// NOTE: AppLovin distributes AppLovinSDK via SPM at the URL below; pin to the newest
-// 13.x tag. RevenueCat's SPM package is purchases-ios.
-
-import PackageDescription
-
-let package = Package(
- name: "RustyNesApp",
- platforms: [
- .iOS(.v14) // AppLovin MAX 13 / RevenueCat current minimums
- ],
- products: [
- .library(name: "RustyNesApp", targets: ["RustyNesApp"])
- ],
- dependencies: [
- // Local package generated from the Rust core by `cargo swift package`.
- .package(path: "../RustyNesMonetization"),
- // RevenueCat — pin to the newest tag before release.
- .package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.0.0"),
- // AppLovin MAX — pin to the newest 13.x tag before release.
- .package(url: "https://github.com/AppLovin/AppLovin-MAX-Swift-Package.git", from: "13.0.0"),
- ],
- targets: [
- .target(
- name: "RustyNesApp",
- dependencies: [
- .product(name: "RustyNesMonetization", package: "RustyNesMonetization"),
- .product(name: "RevenueCat", package: "purchases-ios"),
- .product(name: "AppLovinSDK", package: "AppLovin-MAX-Swift-Package"),
- ],
- path: "Sources/RustyNesApp"
- )
- ]
-)
diff --git a/crates/rustynes-monetization/shells/ios/RewardedGate.swift b/crates/rustynes-monetization/shells/ios/RewardedGate.swift
deleted file mode 100644
index 7e2664ae..00000000
--- a/crates/rustynes-monetization/shells/ios/RewardedGate.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-//
-// RewardedGate.swift — AppLovin MAX rewarded-ad gate for iOS (the free-tier engine).
-//
-// Direct counterpart of Android's RewardedGate.kt and the sibling of AdGate.swift
-// (interstitials). Owns the MAX rewarded lifecycle (preload -> show -> reload) and
-// extends the free-tier play budget: on the network's REWARD callback (didRewardUser)
-// it calls the shared core's grantRewardedTime() — never on load/show/dismiss — then
-// resumes the paused emulator.
-//
-// Cadence/cap policy lives in the core: gate the offer on core.canOfferRewarded() and
-// label it with core.rewardGrantsRemaining(); grantRewardedTime() enforces the 2-grant
-// cap and returns false (no-op) once reached.
-//
-// Usage from the run-out prompt:
-// let rewarded = RewardedGate(core: core) { resumeEmulator() }
-// rewarded.preload() // early, and ~60-90 s before run-out
-// if core.canOfferRewarded() { _ = rewarded.show() } // on "Watch ad for +11 min"
-//
-
-import Foundation
-import AppLovinSDK
-import RustyNesMonetization
-
-final class RewardedGate: NSObject, MARewardedAdDelegate {
- private let core: AdPolicy
- private let rewarded: MARewardedAd
- /// Called after a granted reward so the host can resume the paused emulator.
- private let onResume: () -> Void
-
- init(core: AdPolicy, onResume: @escaping () -> Void = {}) {
- self.core = core
- self.rewarded = MARewardedAd.shared(withAdUnitIdentifier: Config.maxRewardedAdUnitId)
- self.onResume = onResume
- super.init()
- self.rewarded.delegate = self
- }
-
- /// Warm the cache so the "+11 min" tap plays instantly. Call early, and ~60-90 s before run-out.
- func preload() {
- if !core.isPremium() { rewarded.load() }
- }
-
- /// Present a rewarded ad if one is ready. Reach here only when core.canOfferRewarded()
- /// is true. Returns false if no ad was ready (caller falls back to the Full Version
- /// prompt or the offline-grace path, core.canGrantOfflineGrace()).
- @discardableResult
- func show() -> Bool {
- if core.isPremium() || !core.canOfferRewarded() { return false }
- if rewarded.isReady {
- rewarded.show()
- return true
- }
- rewarded.load() // not cached yet; caller handles the no-ad-available case
- return false
- }
-
- // MARK: - MARewardedAdDelegate
-
- /// The ONLY place a grant happens — the user watched for the required duration.
- /// grantRewardedTime() adds +reward_play_ms (enforcing the per-session cap), then resume.
- func didRewardUser(forAd ad: MAAd, with reward: MAReward) {
- _ = core.grantRewardedTime()
- onResume()
- }
-
- func didLoad(_ ad: MAAd) { /* cached; shown explicitly via show() */ }
- func didDisplay(_ ad: MAAd) { /* no-op */ }
-
- func didHide(_ ad: MAAd) {
- // Reload for the next run-out. If didRewardUser did NOT fire (user bailed early),
- // no grant was made; the caller's resume path still runs the paused emulator.
- if !core.isPremium() { rewarded.load() }
- }
-
- func didClick(_ ad: MAAd) { /* no-op */ }
- func didFail(toLoadAdForAdUnitIdentifier adUnitIdentifier: String, withError error: MAError) {
- // optional: exponential backoff before retrying load()
- }
- func didFail(toDisplay ad: MAAd, withError error: MAError) {
- if !core.isPremium() { rewarded.load() }
- }
-}
diff --git a/crates/rustynes-monetization/shells/ios/RustyNesApp.swift b/crates/rustynes-monetization/shells/ios/RustyNesApp.swift
deleted file mode 100644
index 14474ba9..00000000
--- a/crates/rustynes-monetization/shells/ios/RustyNesApp.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-//
-// RustyNesApp.swift — iOS entry point + monetization coordinator.
-//
-// Mirrors the Android RustyNesApp.kt. It:
-// 1. Builds the single shared `AdPolicy` from the Rust core (RustyNesMonetization module).
-// 2. Initializes AppLovin MAX with the current config-builder init API.
-// 3. Configures RevenueCat and binds the premium entitlement into the core.
-//
-// The `Monetization` singleton holds the process-wide core, billing wrapper, and ad
-// gate so SwiftUI views can reach them through the environment or directly.
-//
-// SDK sources (see Package.swift): RevenueCat (purchases-ios), AppLovinSDK, and the
-// RustyNesMonetization Swift package produced from the Rust core by cargo-swift (see README).
-//
-
-import SwiftUI
-import AppLovinSDK
-import RevenueCat
-import RustyNesMonetization
-
-/// Build-time configuration. Inject real values via an .xcconfig / Info.plist rather
-/// than committing secrets. Placeholders keep the skeleton self-contained.
-enum Config {
- static let appLovinSdkKey = (Bundle.main.object(forInfoDictionaryKey: "APPLOVIN_SDK_KEY") as? String) ?? ""
- static let revenueCatApiKey = (Bundle.main.object(forInfoDictionaryKey: "REVENUECAT_API_KEY") as? String) ?? ""
- static let maxInterstitialAdUnitId =
- (Bundle.main.object(forInfoDictionaryKey: "MAX_INTERSTITIAL_AD_UNIT_ID") as? String) ?? ""
-
- /// RevenueCat entitlement identifier; a single entitlement gates everything.
- static let entitlementPremium = "premium"
-}
-
-/// Process-wide monetization coordinator.
-final class Monetization {
- static let shared = Monetization()
-
- /// Shared policy core. `defaultAdConfig()` / `AdPolicy(config:nowMs:)` are RustyNesMonetization bindings.
- let core: AdPolicy
- let billing: Billing
- let adGate: AdGate
-
- private init() {
- // Monotonic milliseconds INCLUDING deep sleep, matching Android's
- // SystemClock.elapsedRealtime() (the clock the Rust core was built against).
- // `mach_continuous_time()` keeps ticking while the device sleeps, whereas
- // DispatchTime.uptimeNanoseconds pauses — using the latter would let the ad
- // cooldown + play-time pacing diverge between iOS and Android.
- core = AdPolicy(config: defaultAdConfig(), nowMs: Self.continuousMs())
- billing = Billing(core: core)
- adGate = AdGate(core: core)
- }
-
- /// Monotonic milliseconds including deep sleep — the iOS analogue of Android's
- /// `SystemClock.elapsedRealtime()`. Shared by the AdGate so both clocks agree.
- static func continuousMs() -> UInt64 {
- var info = mach_timebase_info()
- mach_timebase_info(&info)
- let nanos = mach_continuous_time() * UInt64(info.numer) / UInt64(info.denom)
- return nanos / 1_000_000
- }
-
- /// Call once at launch (from the App's init).
- func start() {
- // The ad + billing SDKs initialize ONLY in the App Store build. Sideload /
- // TestFlight-without-monetization builds (PLAY_BUILD off) stay full-featured and
- // ad-free, so the AppLovin + RevenueCat SDKs never initialize there.
- #if PLAY_BUILD
- // (2) Initialize AppLovin MAX (config-builder API). Do this as early as possible
- // so the SDK has maximum time to pre-cache mediated networks' ads.
- let initConfig = ALSdkInitializationConfiguration(sdkKey: Config.appLovinSdkKey) { builder in
- builder.mediationProvider = ALMediationProviderMAX
- }
- ALSdk.shared().initialize(with: initConfig) { _ in
- // SDK ready — views may now preload/show interstitials via adGate.
- }
-
- // (3) Configure RevenueCat and bind the entitlement → core (initial fetch +
- // live delegate updates on purchase / restore / expiry).
- #if DEBUG
- Purchases.logLevel = .debug
- #else
- Purchases.logLevel = .info
- #endif
- Purchases.configure(withAPIKey: Config.revenueCatApiKey)
- billing.bindEntitlement()
- #endif
- }
-}
-
-@main
-struct RustyNesApp: App {
- init() {
- Monetization.shared.start()
- }
-
- var body: some Scene {
- WindowGroup {
- // Replace with the emulator's root view.
- Text("RustyNES")
- }
- }
-}
diff --git a/crates/rustynes-monetization/src/lib.rs b/crates/rustynes-monetization/src/lib.rs
deleted file mode 100644
index 300ba800..00000000
--- a/crates/rustynes-monetization/src/lib.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-//! lib.rs — Crate root for `rustynes_monetization`.
-//!
-//! This is the mobile **monetization bridge** crate, intentionally separate from the
-//! RustyNES emulator crates (`rustynes-cpu/ppu/apu/mappers/core`). It carries only the
-//! cross-platform entitlement + ad-pacing policy that the Android and iOS shells share via
-//! UniFFI. It does NOT contain emulator logic and must never feed state into the
-//! deterministic emulation core (see `monetization.rs` for why).
-//!
-//! `uniffi::setup_scaffolding!()` walks every `#[uniffi::export]` / `#[derive(uniffi::*)]`
-//! item in the crate (including those in `monetization`) and generates the C-ABI glue
-//! that the Kotlin and Swift bindings bind against. It MUST be called precisely once,
-//! at the crate root, and the crate name passed implicitly must match the library
-//! name in `Cargo.toml` (`rustynes_monetization`).
-//!
-//! Build & binding generation are documented in docs/build-and-bindings.md.
-
-mod monetization;
-
-// Re-export the public monetization surface at the crate root for ergonomic Rust use
-// (tests, other internal modules). This does not affect the generated FFI, which is
-// driven by the proc-macro attributes themselves.
-pub use monetization::{AdConfig, AdPolicy, PremiumFeature, default_ad_config};
-
-uniffi::setup_scaffolding!();
diff --git a/crates/rustynes-monetization/src/monetization.rs b/crates/rustynes-monetization/src/monetization.rs
deleted file mode 100644
index ec4ab7b4..00000000
--- a/crates/rustynes-monetization/src/monetization.rs
+++ /dev/null
@@ -1,859 +0,0 @@
-//! monetization.rs — Cross-platform entitlement & ad-pacing policy for RustyNES.
-//!
-//! # Monetization model (PRIMARY: ad-supported freemium)
-//! The chosen model is an **ad-supported freemium** built on **RevenueCat** (entitlement /
-//! billing) + **AppLovin MAX** (ad mediation). This is a deliberate maintainer override of the
-//! ad-free default sketched in `to-dos/plans/v1.8.0-android-plan.md`: instead of a pure demo
-//! timer, the free tier shows **interstitials** at natural breaks and offers **rewarded ads**
-//! that extend play time, and the paid tier is a one-time **"Full Version / Remove Ads"**
-//! purchase (**$3.99**) keyed to the RevenueCat `premium` entitlement. Every method on the
-//! types below is in scope. See `docs/rustynes-integration.md` for how this maps onto the real
-//! RustyNES repo (the Compose + wgpu-`SurfaceView` hybrid app, the `rustynes-mobile` bridge).
-//!
-//! # Purpose
-//! This module is the single source of truth for the monetization behavior that MUST
-//! stay identical between the Android and iOS builds:
-//!
-//! 1. **Entitlement state** — is the current user a paying ("premium") customer?
-//! 2. **Ad pacing** — given that they are *not* premium, is *now* an acceptable
-//! moment to show an interstitial ad? (Paced by a launch grace + a minimum interval;
-//! there is no per-session interstitial count cap.)
-//! 3. **Free-tier play-time gate** — a free user gets a base play budget per game
-//! session (8 min), extendable +11 min by each completed rewarded ad, capped at 2
-//! grants/session (→ 30 min max). Premium removes the gate entirely.
-//!
-//! The platform shells (Kotlin / Swift) own the *plumbing*: they talk to **RevenueCat** for the
-//! entitlement and to **AppLovin MAX** for the actual ad load/show, then feed facts in
-//! (`set_premium`, `notify_interstitial_shown`) and ask questions (`should_show_interstitial`,
-//! `feature_enabled`). They own no policy. Because both shells call the *same* Rust object
-//! through generated UniFFI bindings, the cadence rules and the paid-feature set cannot drift
-//! between platforms — the cross-platform-share rationale behind the planned `rustynes-mobile`
-//! bridge.
-//!
-//! # Why time is injected
-//! The host passes a monotonic millisecond timestamp (`now_ms`) into every
-//! time-dependent call rather than letting this module read a clock. That keeps the
-//! pacing logic pure and deterministic, so the unit tests below fully exercise it
-//! without mocking a system clock — the same discipline used for the emulator core.
-//!
-//! On the host side, "monotonic milliseconds" means:
-//! * Android: `android.os.SystemClock.elapsedRealtime()` (Long → ULong)
-//! * iOS: `DispatchTime.now().uptimeNanoseconds / 1_000_000` (UInt64)
-//!
-//! # Usage (host pseudocode)
-//! ```text
-//! let policy = AdPolicy::new(AdConfig::default(), now_ms); // once, at launch
-//! policy.set_premium(rc_entitlement_active); // from RevenueCat
-//! if policy.should_show_interstitial(now_ms) { // at a natural break
-//! // host loads + shows a MAX interstitial, then on "hidden":
-//! policy.notify_interstitial_shown(now_ms);
-//! }
-//!
-//! // free-tier play-time gate, per game session:
-//! policy.start_play(); // when a game begins
-//! // ...once per second of unpaused emulation:
-//! policy.add_active_time(1000);
-//! if !policy.is_play_allowed() { // budget exhausted
-//! // pause; offer "Watch ad for +11 min" only if policy.can_offer_rewarded(),
-//! // else offer only "Buy Full Version". On the rewarded reward callback:
-//! policy.grant_rewarded_time(); // +11 min (capped at 2)
-//! }
-//! ```
-//!
-//! All public items here are exported across the FFI boundary by `lib.rs`'s
-//! `uniffi::setup_scaffolding!()`, so they appear in both the Kotlin and the Swift
-//! bindings with the names documented inline below.
-//!
-//! # Determinism boundary (RustyNES-specific, load-bearing)
-//! RustyNES guarantees bit-identical output for a given (ROM, input, seed) — the contract
-//! that makes save-state round-trips, rollback netplay, TAS replay, and RetroAchievements
-//! correct. NONE of this module's state (premium flag, play budget, ad cadence, the
-//! host-injected `now_ms`) may ever flow into `rustynes-core::Bus` or the scheduler.
-//! Monetization is strictly a frontend/host concern that reads emulator wall-time and
-//! pauses the emulation thread; it must not influence emulated state. Keep it that way.
-
-use std::sync::Mutex;
-
-/// Tunable pacing parameters, exposed to the host so the values can be sourced from
-/// a remote config / experiment without rebuilding the Rust core.
-///
-/// The first two fields pace *interstitials*; the last three define the free-tier
-/// *play-time budget* and the rewarded "+11 min per ad" extension (see the play-time
-/// methods on [`AdPolicy`]).
-///
-/// Generated binding names (Kotlin `data class` / Swift `struct`): `minIntervalMs`,
-/// `launchGraceMs`, `basePlayMs`, `rewardPlayMs`, `maxRewardGrantsPerSession`,
-/// `firstSessionPlayMs`, `suppressFirstSession`, `offlineGraceMs` — `ULong`/`UInt64` for the
-/// `*_ms` fields, `UInt`/`UInt32` for the grant cap, `Boolean`/`Bool` for the suppress flag.
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct AdConfig {
- /// Minimum elapsed time between two interstitials, in milliseconds.
- pub min_interval_ms: u64,
- /// Quiet period immediately after launch during which no interstitial is shown.
- pub launch_grace_ms: u64,
- /// Free-tier base play budget granted at the start of each game session, in ms.
- pub base_play_ms: u64,
- /// Play time granted per *completed* rewarded ad, in ms.
- pub reward_play_ms: u64,
- /// Maximum number of rewarded "+time" grants allowed per game session. Once this
- /// many grants have been given, the rewarded offer is withdrawn and only the
- /// Full Version prompt remains.
- pub max_reward_grants_per_session: u32,
- /// First-session play budget, in ms — applied by [`AdPolicy::start_play`] when the
- /// session index is 1, so a brand-new user gets a generous (or ungated) first game
- /// before the timer bites. Set very large for an effectively ungated first session.
- pub first_session_play_ms: u64,
- /// When `true`, no interstitial is shown during session #1 (protect the first
- /// impression). Paired with the session index fed via [`AdPolicy::begin_session`].
- pub suppress_first_session: bool,
- /// One-time, per-game-session "offline grace" budget, in ms — granted by
- /// [`AdPolicy::grant_offline_grace`] when a free user hits the wall but no rewarded ad
- /// can load (offline / no fill), so the session degrades gracefully instead of
- /// dead-ending. `0` disables the grace.
- pub offline_grace_ms: u64,
-}
-
-impl Default for AdConfig {
- /// Conservative defaults tuned for an emulator: long, focused play sessions where
- /// an interruption is more jarring than in a casual game. 4-minute spacing with a
- /// 30-second launch grace keeps ads from ever bracketing app startup.
- ///
- /// Free-tier defaults: an **8-minute** base budget (regular sessions) with a generous
- /// **30-minute** first session, **+11 minutes per completed rewarded ad, capped at 2
- /// grants** per session — so a fully ad-engaged free user reaches at most 8 + (2 × 11) =
- /// 30 minutes of play in a regular game session with only two ad interactions.
- fn default() -> Self {
- Self {
- min_interval_ms: 240_000, // 4 minutes
- launch_grace_ms: 30_000, // 30 seconds
- base_play_ms: 480_000, // 8 minutes (regular free session)
- reward_play_ms: 660_000, // 11 minutes per rewarded ad
- max_reward_grants_per_session: 2, // → +22 min max (2 × 11) → 30 min total
- first_session_play_ms: 1_800_000, // 30 minutes — generous first game
- suppress_first_session: true, // no interstitials in session #1
- offline_grace_ms: 120_000, // a one-time +2 min when offline at run-out
- }
- }
-}
-
-/// Provide `AdConfig::default()` to the foreign side as a free function, because
-/// UniFFI Records do not carry methods across the FFI. Hosts that want the tuned
-/// defaults call this instead of hand-constructing the struct.
-///
-/// Generated binding names: `defaultAdConfig()` (Kotlin) / `defaultAdConfig()` (Swift).
-#[uniffi::export]
-pub fn default_ad_config() -> AdConfig {
- AdConfig::default()
-}
-
-/// The set of features that are gated behind the **Full Version** entitlement. Centralizing
-/// the list here is what guarantees Android and iOS gate the identical set.
-///
-/// **Two groups (maintainer decision 2026-06-23 — "expand the premium set"):**
-///
-/// *Persistence* (the original three locks the free demo disables):
-/// * `SaveStates` → the F1/F4 save/load slots + the thumbnail Save-States manager.
-/// * `SaveOnExitResume` → write-an-`auto`-state on background + auto-resume on relaunch.
-/// * `BatterySaves` → persisting on-cart battery-backed SRAM (and FDS RAM) to disk.
-///
-/// *Power features* (newly premium — this **overrides** the earlier doc stance that
-/// fast-forward was free; the free tier keeps full accuracy, video, audio, input, pause,
-/// and in-session rewind, but these power tools now require the unlock):
-/// * `FastForward` → the fast-forward / turbo speed toggle.
-/// * `Shaders` → the NTSC / CRT / scanline / Bisqwit shader stack (free = plain).
-/// * `Cheats` → Game Genie + raw-RAM cheat entry.
-///
-/// The free tier is also time-gated per game session (see the play-time methods on
-/// [`AdPolicy`]); purchasing the Full Version removes the timer and lifts all six locks.
-/// RetroAchievements is deferred from the Android MVP, so its hardcore-mode save/rewind
-/// disabling is a later-increment concern.
-///
-/// Generated binding names:
-/// * Kotlin: `enum class PremiumFeature { SAVE_STATES, SAVE_ON_EXIT_RESUME, BATTERY_SAVES,
-/// FAST_FORWARD, SHADERS, CHEATS }`
-/// * Swift: `enum PremiumFeature { case saveStates; …; case fastForward; case shaders; case cheats }`
-#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
-pub enum PremiumFeature {
- /// Save / load emulator save-states (F1/F4 + the Save-States manager). Free: disabled.
- SaveStates,
- /// Save-on-background (`onPause` writes an `auto` state) + auto-resume on relaunch.
- /// Free: disabled (a ROM never auto-resumes; no `auto` state is written).
- SaveOnExitResume,
- /// Persisting on-cart battery-backed SRAM (and FDS RAM) to disk so progress survives a
- /// close. Free: never written to disk (in-session battery RAM still works).
- BatterySaves,
- /// Fast-forward / turbo speed. Free: disabled (normal-speed play only).
- FastForward,
- /// The NTSC / CRT / scanline / Bisqwit shader stack. Free: plain (unfiltered) output.
- Shaders,
- /// Game Genie + raw-RAM cheat entry. Free: disabled.
- Cheats,
-}
-
-/// Interior, mutable state guarded by a `Mutex` so the host may call from any thread
-/// (RevenueCat callbacks, ad callbacks, and the UI thread can all touch it).
-struct State {
- /// Whether the premium entitlement is currently active.
- is_premium: bool,
- /// Monotonic timestamp captured at construction; anchors the launch grace window.
- launched_at_ms: u64,
- /// Monotonic timestamp of the last interstitial that was actually shown.
- last_shown_ms: Option,
- /// Total free-tier play budget granted for the current game session, in ms
- /// (base + any rewarded extensions). Reset by `start_play`.
- budget_ms: u64,
- /// Active (unpaused) play time consumed in the current game session, in ms.
- consumed_ms: u64,
- /// Number of rewarded "+time" grants already given in the current game session;
- /// compared against `AdConfig::max_reward_grants_per_session` to enforce the cap.
- reward_grants_this_session: u32,
- /// The app-session index (1 on the very first launch, incremented by the host each
- /// app session via `begin_session`). Drives first-session interstitial suppression
- /// and the generous first-session play budget.
- session_index: u32,
- /// Whether this game session's one-time offline-grace continuation has been spent.
- offline_grace_used: bool,
-}
-
-/// The policy object the host constructs once and holds for the app's lifetime.
-///
-/// UniFFI represents this as a reference-counted handle:
-/// * Kotlin: `class AdPolicy(config: AdConfig, nowMs: ULong) : Disposable`
-/// * Swift: `class AdPolicy { init(config: AdConfig, nowMs: UInt64) }`
-///
-/// Construct with [`AdPolicy::new`].
-#[derive(uniffi::Object)]
-pub struct AdPolicy {
- cfg: AdConfig,
- state: Mutex,
-}
-
-#[uniffi::export]
-impl AdPolicy {
- /// Build a policy. `now_ms` is the host's current monotonic clock reading and
- /// becomes the anchor for the launch-grace window.
- ///
- /// Generated binding names: `AdPolicy(config, nowMs)` (Kotlin) /
- /// `AdPolicy(config:nowMs:)` (Swift).
- #[uniffi::constructor]
- pub fn new(config: AdConfig, now_ms: u64) -> Self {
- Self {
- cfg: config,
- state: Mutex::new(State {
- is_premium: false, // assume free until billing confirms otherwise
- launched_at_ms: now_ms,
- last_shown_ms: None,
- // Play budget is armed by `start_play` when a game actually starts; a
- // freshly constructed policy has no active game session yet.
- budget_ms: 0,
- consumed_ms: 0,
- reward_grants_this_session: 0,
- // Default to session 1 until the host calls `begin_session` with the
- // persisted count; conservative (treats an un-counted launch as the first).
- session_index: 1,
- offline_grace_used: false,
- }),
- }
- }
-
- /// Record the latest premium-entitlement state, as reported by RevenueCat
- /// (`CustomerInfo.entitlements["premium"].isActive`). Setting `true` makes every
- /// ad gate below return `false`, so ads stop immediately and without an app
- /// restart — the upgrade feels instantaneous to the user.
- ///
- /// Generated binding names: `setPremium(premium)` (both languages).
- pub fn set_premium(&self, premium: bool) {
- self.state.lock().unwrap().is_premium = premium;
- }
-
- /// Current paid-tier status. The UI uses this to hide ad containers up front and
- /// to reflect entitlement in menus.
- ///
- /// Generated binding names: `isPremium()` (both languages).
- pub fn is_premium(&self) -> bool {
- self.state.lock().unwrap().is_premium
- }
-
- /// Record the app-session index at launch (the host persists a counter in
- /// SharedPreferences / UserDefaults: 1 on first ever launch, +1 each app session) and
- /// re-anchor the launch-grace window to `now_ms`. Drives first-session interstitial
- /// suppression (`suppress_first_session`) and the generous first-session play budget
- /// (`first_session_play_ms`, applied by the next [`Self::start_play`]).
- ///
- /// Generated binding names: `beginSession(sessionIndex, nowMs)` (both languages).
- pub fn begin_session(&self, session_index: u32, now_ms: u64) {
- let mut s = self.state.lock().unwrap();
- s.session_index = session_index;
- s.launched_at_ms = now_ms;
- }
-
- /// The core decision: should the host present an interstitial *right now*?
- ///
- /// Returns `false` if any of the following hold:
- /// * the user is premium (paid users never see ads),
- /// * we are still inside the post-launch grace window, or
- /// * not enough time has elapsed since the previous interstitial.
- ///
- /// The host should call this only at *natural* break points (ROM loaded, returned
- /// to the menu, save-state taken) — never mid-frame.
- ///
- /// Generated binding names: `shouldShowInterstitial(nowMs)` (both languages).
- pub fn should_show_interstitial(&self, now_ms: u64) -> bool {
- let s = self.state.lock().unwrap();
-
- if s.is_premium {
- return false; // paid → never
- }
- if self.cfg.suppress_first_session && s.session_index <= 1 {
- return false; // protect the first impression — no interstitials in session #1
- }
- if now_ms.saturating_sub(s.launched_at_ms) < self.cfg.launch_grace_ms {
- return false; // too soon after launch
- }
- match s.last_shown_ms {
- None => true, // first eligible break since launch
- Some(last) => now_ms.saturating_sub(last) >= self.cfg.min_interval_ms,
- }
- }
-
- /// Arm the cooldown. The host calls this immediately after an interstitial is
- /// *actually displayed* (e.g. AppLovin's `didDisplay` / `onAdDisplayed`), not when
- /// it merely decides to load one. Keeping this separate from
- /// [`Self::should_show_interstitial`] means a failed ad load does not consume the
- /// interval, so the next break point can retry.
- ///
- /// Generated binding names: `notifyInterstitialShown(nowMs)` (both languages).
- pub fn notify_interstitial_shown(&self, now_ms: u64) {
- self.state.lock().unwrap().last_shown_ms = Some(now_ms);
- }
-
- /// Whether a given premium feature is currently unlocked. This is the single
- /// authority both shells consult before enabling save-states, battery saves, etc.
- ///
- /// Generated binding names: `featureEnabled(feature)` (both languages).
- pub fn feature_enabled(&self, feature: PremiumFeature) -> bool {
- // The free tier keeps the full, accurate emulator; only conveniences and
- // persistence are paywalled. Every gated feature follows the entitlement.
- match feature {
- PremiumFeature::SaveStates
- | PremiumFeature::SaveOnExitResume
- | PremiumFeature::BatterySaves
- | PremiumFeature::FastForward
- | PremiumFeature::Shaders
- | PremiumFeature::Cheats => self.state.lock().unwrap().is_premium,
- }
- }
-
- // ---- Free-tier play-time budget + rewarded extension --------------------------
- //
- // These five calls implement the free-tier time gate: a base budget per game
- // session, extended +reward_play_ms by each completed rewarded ad, capped at
- // max_reward_grants_per_session grants. Premium bypasses the gate entirely. As with
- // the interstitial pacing, the host injects elapsed time (`add_active_time`) rather
- // than the core reading a clock, so the logic stays pure and is unit-tested below.
-
- /// Arm the play budget for a new game session: set it to the allotment (the generous
- /// `first_session_play_ms` during session #1, else `base_play_ms`) and reset the
- /// consumed-time counter, the per-session rewarded-grant counter, and the one-time
- /// offline-grace flag. The host calls this when a game (ROM) actually begins playing.
- ///
- /// Generated binding names: `startPlay()` (both languages).
- pub fn start_play(&self) {
- let mut s = self.state.lock().unwrap();
- s.budget_ms = if s.session_index <= 1 {
- self.cfg.first_session_play_ms
- } else {
- self.cfg.base_play_ms
- };
- s.consumed_ms = 0;
- s.reward_grants_this_session = 0; // the cap resets each game session
- s.offline_grace_used = false; // one offline grace per game session
- }
-
- /// Report active (unpaused) play time elapsed, in ms — typically once per second of
- /// running emulation. The host already pauses emulation for ads, the run-out prompt,
- /// and app-backgrounding, so it simply stops calling this while paused; the core
- /// never reads a clock and so stays deterministic and pause-agnostic.
- ///
- /// Generated binding names: `addActiveTime(deltaMs)` (both languages).
- pub fn add_active_time(&self, delta_ms: u64) {
- let mut s = self.state.lock().unwrap();
- s.consumed_ms = s.consumed_ms.saturating_add(delta_ms);
- }
-
- /// Whether a rewarded "+time" offer should be presented right now: true only for a
- /// free user who is still under the per-session grant cap. Once this returns false,
- /// the host should show *only* the Full Version prompt at the run-out.
- ///
- /// Generated binding names: `canOfferRewarded()` (both languages).
- pub fn can_offer_rewarded(&self) -> bool {
- let s = self.state.lock().unwrap();
- !s.is_premium && s.reward_grants_this_session < self.cfg.max_reward_grants_per_session
- }
-
- /// How many rewarded extensions remain in this game session. Useful for UI such as
- /// "3 ad-extensions left". Returns **0 for premium users** (they have no rewarded offer),
- /// so the value can be shown directly without misreporting "11 left" to a paid user; the
- /// offer itself is still gated on [`Self::can_offer_rewarded`].
- ///
- /// Generated binding names: `rewardGrantsRemaining()` (both languages).
- pub fn reward_grants_remaining(&self) -> u32 {
- let s = self.state.lock().unwrap();
- if s.is_premium {
- return 0; // premium has no rewarded offer; never report grants "remaining"
- }
- self.cfg
- .max_reward_grants_per_session
- .saturating_sub(s.reward_grants_this_session)
- }
-
- /// Grant one rewarded extension (`reward_play_ms`) to the current session, enforcing
- /// the per-session cap. Call this **only** from the ad network's *reward* callback
- /// (AppLovin `OnUserRewarded` / `didRewardUser`) — never on ad load, show, or
- /// dismiss — so the grant maps exactly to an ad the user watched for the required
- /// duration. Returns `true` if the grant was applied, or `false` if the cap had
- /// already been reached (in which case it is a no-op).
- ///
- /// Generated binding names: `grantRewardedTime()` (both languages).
- pub fn grant_rewarded_time(&self) -> bool {
- let mut s = self.state.lock().unwrap();
- if s.reward_grants_this_session >= self.cfg.max_reward_grants_per_session {
- return false; // cap reached — no more free extensions this session
- }
- s.budget_ms = s.budget_ms.saturating_add(self.cfg.reward_play_ms);
- s.reward_grants_this_session += 1;
- true
- }
-
- /// Whether the user may keep playing right now. Premium is always allowed; a free
- /// user is allowed while consumed time is below the granted budget.
- ///
- /// Generated binding names: `isPlayAllowed()` (both languages).
- pub fn is_play_allowed(&self) -> bool {
- let s = self.state.lock().unwrap();
- s.is_premium || s.consumed_ms < s.budget_ms
- }
-
- /// Remaining free-tier play time, in ms. Returns `None` for premium users to signal
- /// "unlimited". Drive the on-screen countdown from this value.
- ///
- /// Generated binding names: `playTimeRemainingMs(): ULong?` (Kotlin) /
- /// `playTimeRemainingMs() -> UInt64?` (Swift).
- pub fn play_time_remaining_ms(&self) -> Option {
- let s = self.state.lock().unwrap();
- if s.is_premium {
- None
- } else {
- Some(s.budget_ms.saturating_sub(s.consumed_ms))
- }
- }
-
- // ---- Offline grace ------------------------------------------------------------
- //
- // When a free user hits the wall but no rewarded ad can load (offline / no fill), a
- // one-time `offline_grace_ms` continuation keeps the session from dead-ending. It is
- // capped at once per game session (reset by `start_play`) so it can't be farmed.
-
- /// Whether a one-time offline-grace continuation is available right now: a free user
- /// who hasn't used it this session, with a non-zero `offline_grace_ms` configured.
- /// The host calls this at the run-out when a rewarded ad failed to load.
- ///
- /// Generated binding names: `canGrantOfflineGrace()` (both languages).
- pub fn can_grant_offline_grace(&self) -> bool {
- let s = self.state.lock().unwrap();
- !s.is_premium && !s.offline_grace_used && self.cfg.offline_grace_ms > 0
- }
-
- /// Grant the one-time offline-grace continuation (`offline_grace_ms`) for this game
- /// session. Returns `true` if applied, `false` if premium, already used, or disabled.
- ///
- /// Generated binding names: `grantOfflineGrace()` (both languages).
- pub fn grant_offline_grace(&self) -> bool {
- let mut s = self.state.lock().unwrap();
- if s.is_premium || s.offline_grace_used || self.cfg.offline_grace_ms == 0 {
- return false;
- }
- s.budget_ms = s.budget_ms.saturating_add(self.cfg.offline_grace_ms);
- s.offline_grace_used = true;
- true
- }
-
- // ---- In-progress persistence --------------------------------------------------
- //
- // The core is in-memory: it forgets the budget/consumed/grant counters when the
- // process dies, so a free user could kill-and-relaunch the same ROM for a fresh
- // budget. The host can close that hole by persisting `export_progress()` (e.g. in
- // SharedPreferences / UserDefaults, keyed by ROM) and `restore_progress()` before the
- // next `start_play()` of the same ROM. Skip it to accept restart-to-reset as intended.
-
- /// Snapshot the current game session's play-gate state for host persistence.
- ///
- /// Generated binding names: `exportProgress()` (both languages).
- pub fn export_progress(&self) -> PlayProgress {
- let s = self.state.lock().unwrap();
- PlayProgress {
- budget_ms: s.budget_ms,
- consumed_ms: s.consumed_ms,
- reward_grants_this_session: s.reward_grants_this_session,
- offline_grace_used: s.offline_grace_used,
- }
- }
-
- /// Restore a previously-[`Self::export_progress`]'d snapshot (e.g. after a relaunch),
- /// so the timer/cap survive a kill mid-run. Call instead of (or right after)
- /// `start_play` when resuming the same ROM. Ignored for premium (no gate to restore).
- ///
- /// Generated binding names: `restoreProgress(progress)` (both languages).
- pub fn restore_progress(&self, progress: PlayProgress) {
- let mut s = self.state.lock().unwrap();
- if s.is_premium {
- return;
- }
- s.budget_ms = progress.budget_ms;
- s.consumed_ms = progress.consumed_ms;
- s.reward_grants_this_session = progress.reward_grants_this_session;
- s.offline_grace_used = progress.offline_grace_used;
- }
-}
-
-/// A serializable snapshot of a game session's free-tier play-gate state, for the host to
-/// persist across a process kill (closing the restart-to-reset hole). UniFFI marshals it
-/// as a plain record; the host stores the four fields however it likes.
-///
-/// Generated binding names:
-/// * Kotlin: `data class PlayProgress(budgetMs: ULong, consumedMs: ULong,
-/// rewardGrantsThisSession: UInt, offlineGraceUsed: Boolean)`
-/// * Swift: `struct PlayProgress { var budgetMs: UInt64; var consumedMs: UInt64;
-/// var rewardGrantsThisSession: UInt32; var offlineGraceUsed: Bool }`
-#[derive(Debug, Clone, Copy, uniffi::Record)]
-pub struct PlayProgress {
- /// Total granted budget for the session, in ms (base/first-session + rewarded + grace).
- pub budget_ms: u64,
- /// Active play time consumed so far this session, in ms.
- pub consumed_ms: u64,
- /// Rewarded "+time" grants already given this session (counts against the cap).
- pub reward_grants_this_session: u32,
- /// Whether this session's one-time offline grace has been spent.
- pub offline_grace_used: bool,
-}
-
-/// Clamp every [`AdConfig`] field to a sane range so a bad remote-config push (e.g. a
-/// 0-ms base budget, or an absurd grant cap) can't brick the gate. The host fetches remote
-/// values, overlays them on [`default_ad_config`], then passes the result through this
-/// before constructing the [`AdPolicy`]. Pure + total; ranges mirror the addendum §9 table.
-///
-/// Generated binding names: `clampAdConfig(cfg)` (both languages).
-#[uniffi::export]
-pub fn clamp_ad_config(cfg: AdConfig) -> AdConfig {
- AdConfig {
- min_interval_ms: cfg.min_interval_ms.clamp(60_000, 1_800_000), // 1 min .. 30 min
- launch_grace_ms: cfg.launch_grace_ms.min(600_000), // .. 10 min
- base_play_ms: cfg.base_play_ms.clamp(60_000, 3_600_000), // 1 min .. 60 min
- reward_play_ms: cfg.reward_play_ms.clamp(30_000, 1_200_000), // 30 s .. 20 min
- max_reward_grants_per_session: cfg.max_reward_grants_per_session.min(50),
- // 1 min .. 4 h: floored at 60_000 so a remote `0` can't brick the first session
- // (set a large value, not 0, for an effectively ungated first game).
- first_session_play_ms: cfg.first_session_play_ms.clamp(60_000, 14_400_000),
- suppress_first_session: cfg.suppress_first_session,
- offline_grace_ms: cfg.offline_grace_ms.min(600_000), // .. 10 min; 0 disables
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- /// A tiny fixed config makes the timing assertions easy to read. The play-time
- /// values here are deliberately small (8s base, 2s reward, cap 3) so the tests run
- /// instantly; the *production* constants (8 min / 11 min / 2) are pinned separately
- /// in `default_config_encodes_the_30_minute_contract`.
- fn cfg() -> AdConfig {
- AdConfig {
- min_interval_ms: 1_000,
- launch_grace_ms: 100,
- base_play_ms: 8_000,
- reward_play_ms: 2_000,
- max_reward_grants_per_session: 3,
- // first-session budget == base, and first-session suppression OFF, so the
- // existing pacing/budget tests below exercise the grace/interval/budget logic
- // in isolation. The first-session behaviour has its own dedicated tests.
- first_session_play_ms: 8_000,
- suppress_first_session: false,
- offline_grace_ms: 1_000,
- }
- }
-
- #[test]
- fn premium_users_never_see_ads() {
- let p = AdPolicy::new(cfg(), 0);
- p.set_premium(true);
- // Well past the grace window and the interval — still no ad, because premium.
- assert!(!p.should_show_interstitial(10_000));
- assert!(p.is_premium());
- }
-
- #[test]
- fn launch_grace_suppresses_early_ads() {
- let p = AdPolicy::new(cfg(), 0);
- assert!(!p.should_show_interstitial(50)); // inside the 100ms grace
- assert!(p.should_show_interstitial(150)); // just past it
- }
-
- #[test]
- fn interval_is_enforced_between_shows() {
- let p = AdPolicy::new(cfg(), 0);
- assert!(p.should_show_interstitial(150)); // first eligible break
- p.notify_interstitial_shown(150);
- assert!(!p.should_show_interstitial(800)); // 650ms later: too soon
- assert!(p.should_show_interstitial(1_150)); // 1000ms later: allowed
- }
-
- #[test]
- fn upgrading_mid_session_stops_ads_immediately() {
- let p = AdPolicy::new(cfg(), 0);
- assert!(p.should_show_interstitial(150));
- p.set_premium(true); // user buys "remove ads"
- assert!(!p.should_show_interstitial(150));
- }
-
- #[test]
- fn features_track_entitlement() {
- let p = AdPolicy::new(cfg(), 0);
- let all = [
- PremiumFeature::SaveStates,
- PremiumFeature::SaveOnExitResume,
- PremiumFeature::BatterySaves,
- PremiumFeature::FastForward,
- PremiumFeature::Shaders,
- PremiumFeature::Cheats,
- ];
- for f in all {
- assert!(!p.feature_enabled(f), "free tier must gate {f:?}");
- }
- p.set_premium(true);
- for f in all {
- assert!(p.feature_enabled(f), "premium must unlock {f:?}");
- }
- }
-
- // ---- Free-tier play-time budget + rewarded cap --------------------------------
-
- #[test]
- fn free_tier_starts_with_the_base_budget() {
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- assert_eq!(p.play_time_remaining_ms(), Some(8_000));
- assert!(p.is_play_allowed());
-
- // Consume the whole base budget → no time left, play disallowed.
- p.add_active_time(8_000);
- assert_eq!(p.play_time_remaining_ms(), Some(0));
- assert!(!p.is_play_allowed());
- }
-
- #[test]
- fn paused_time_does_not_consume_budget() {
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- p.add_active_time(3_000); // 3s of active play
- // Simulate a long pause (an ad, the run-out prompt, backgrounding): no calls.
- assert_eq!(p.play_time_remaining_ms(), Some(5_000));
- assert!(p.is_play_allowed());
- }
-
- #[test]
- fn a_rewarded_grant_extends_play() {
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- p.add_active_time(8_000); // exhaust the base budget
- assert!(!p.is_play_allowed());
-
- assert!(p.grant_rewarded_time()); // +2s
- assert_eq!(p.play_time_remaining_ms(), Some(2_000));
- assert!(p.is_play_allowed());
- }
-
- #[test]
- fn rewarded_grants_are_capped_per_session() {
- let p = AdPolicy::new(cfg(), 0); // cap = 3 in the test config
- p.start_play();
-
- assert!(p.can_offer_rewarded());
- assert_eq!(p.reward_grants_remaining(), 3);
-
- assert!(p.grant_rewarded_time()); // 1
- assert!(p.grant_rewarded_time()); // 2
- assert!(p.grant_rewarded_time()); // 3 — cap reached
- assert_eq!(p.reward_grants_remaining(), 0);
- assert!(!p.can_offer_rewarded()); // offer withdrawn at the cap
-
- // The 4th grant is refused and is a no-op (budget unchanged beyond the 3 grants).
- assert!(!p.grant_rewarded_time());
- assert_eq!(p.play_time_remaining_ms(), Some(8_000 + 3 * 2_000));
- }
-
- #[test]
- fn start_play_resets_budget_and_cap() {
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- p.grant_rewarded_time();
- p.grant_rewarded_time();
- p.add_active_time(5_000);
-
- // A new game session wipes consumed time, restores the base budget, and re-arms
- // all rewarded grants.
- p.start_play();
- assert_eq!(p.play_time_remaining_ms(), Some(8_000));
- assert_eq!(p.reward_grants_remaining(), 3);
- assert!(p.can_offer_rewarded());
- }
-
- #[test]
- fn premium_play_is_unlimited_and_offer_free() {
- let p = AdPolicy::new(cfg(), 0);
- p.set_premium(true);
- p.start_play();
- p.add_active_time(1_000_000); // play for ages
- assert!(p.is_play_allowed());
- assert_eq!(p.play_time_remaining_ms(), None); // None == unlimited
- assert!(!p.can_offer_rewarded()); // premium never needs the rewarded offer
- assert_eq!(p.reward_grants_remaining(), 0); // premium reports 0, not the cap
- }
-
- #[test]
- fn default_config_encodes_the_30_minute_contract() {
- // Pin the production constants: 8-min base, +11-min grants, 2-grant cap, which
- // is exactly 8 + 2*11 = 30 minutes of maximum free play per game session (only
- // two ad interactions instead of eleven).
- let c = default_ad_config();
- assert_eq!(c.base_play_ms, 480_000);
- assert_eq!(c.reward_play_ms, 660_000);
- assert_eq!(c.max_reward_grants_per_session, 2);
- let max_free_ms =
- c.base_play_ms + c.max_reward_grants_per_session as u64 * c.reward_play_ms;
- assert_eq!(max_free_ms, 1_800_000); // 30 minutes
- }
-
- #[test]
- fn granted_entitlement_fully_unlocks_app() {
- // A RevenueCat *promotional grant* and a Google Play *license-tester* sandbox
- // purchase both surface as entitlements["premium"].isActive == true, which the
- // shells forward via set_premium(true). This pins the contract a closed-test
- // cohort relies on: the single boolean unlocks every gate, with no tester-only
- // code path. (See runbook §5a / brief §9 for how testers are granted access.)
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- p.set_premium(true); // as if RevenueCat reported a granted / sandbox entitlement
-
- // Ads off, all paid features on.
- assert!(!p.should_show_interstitial(10_000));
- assert!(p.feature_enabled(PremiumFeature::SaveStates));
- assert!(p.feature_enabled(PremiumFeature::SaveOnExitResume));
- assert!(p.feature_enabled(PremiumFeature::BatterySaves));
-
- // Timer removed: play never blocks and the run-out offer never appears.
- p.add_active_time(60 * 60 * 1000); // an hour of continuous play
- assert!(p.is_play_allowed());
- assert_eq!(p.play_time_remaining_ms(), None);
- assert!(!p.can_offer_rewarded());
-
- // Revoking the grant (e.g. after the 14-day test window) re-locks immediately.
- p.set_premium(false);
- assert!(!p.is_play_allowed()); // consumed time already exceeds the free budget
- assert!(!p.feature_enabled(PremiumFeature::SaveStates));
- }
-
- /// A config where the first session is generous (20s budget) + interstitials are
- /// suppressed, but later sessions fall back to the 8s base + normal pacing.
- fn first_session_cfg() -> AdConfig {
- AdConfig {
- first_session_play_ms: 20_000,
- suppress_first_session: true,
- ..cfg()
- }
- }
-
- #[test]
- fn first_session_is_generous_and_ad_free() {
- let p = AdPolicy::new(first_session_cfg(), 0);
- // Session 1 (the default): generous budget, no interstitials even past grace/interval.
- p.start_play();
- assert_eq!(p.play_time_remaining_ms(), Some(20_000));
- assert!(!p.should_show_interstitial(10_000));
-
- // Session 2: back to the base budget and normal interstitial pacing.
- p.begin_session(2, 0);
- p.start_play();
- assert_eq!(p.play_time_remaining_ms(), Some(8_000));
- assert!(p.should_show_interstitial(10_000));
- }
-
- #[test]
- fn offline_grace_is_one_time_per_session() {
- let p = AdPolicy::new(cfg(), 0); // offline_grace_ms = 1_000
- p.start_play();
- p.add_active_time(8_000); // exhaust the base budget
- assert!(!p.is_play_allowed());
-
- assert!(p.can_grant_offline_grace());
- assert!(p.grant_offline_grace()); // +1s
- assert_eq!(p.play_time_remaining_ms(), Some(1_000));
- assert!(p.is_play_allowed());
-
- // Only once per session.
- assert!(!p.can_grant_offline_grace());
- assert!(!p.grant_offline_grace());
-
- // A new game session re-arms it.
- p.start_play();
- assert!(p.can_grant_offline_grace());
-
- // Premium never needs it.
- p.set_premium(true);
- assert!(!p.can_grant_offline_grace());
- assert!(!p.grant_offline_grace());
- }
-
- #[test]
- fn progress_round_trips_across_a_relaunch() {
- let p = AdPolicy::new(cfg(), 0);
- p.start_play();
- p.add_active_time(3_000);
- assert!(p.grant_rewarded_time()); // budget 8_000 -> 10_000, 1 grant used
- let snap = p.export_progress();
- assert_eq!(snap.consumed_ms, 3_000);
- assert_eq!(snap.budget_ms, 10_000);
- assert_eq!(snap.reward_grants_this_session, 1);
-
- // Simulate a kill + relaunch: a fresh policy, restore instead of a fresh budget.
- let p2 = AdPolicy::new(cfg(), 0);
- p2.restore_progress(snap);
- assert_eq!(p2.play_time_remaining_ms(), Some(7_000)); // 10_000 - 3_000
- assert_eq!(p2.reward_grants_remaining(), 2); // cap 3, 1 used
- }
-
- #[test]
- fn clamp_ad_config_bounds_remote_values() {
- // A hostile/bad remote push: a 0-ms base budget would brick the gate; absurd
- // interval + cap. Clamp pulls everything back into the safe ranges.
- let bad = AdConfig {
- min_interval_ms: 10, // -> 60_000 floor
- base_play_ms: 0, // -> 60_000 floor (never a 0-ms gate)
- reward_play_ms: 1, // -> 30_000 floor
- max_reward_grants_per_session: 9_999, // -> 50 cap
- first_session_play_ms: 0, // -> 60_000 floor (a 0 would brick session #1)
- ..default_ad_config()
- };
- let c = clamp_ad_config(bad);
- assert_eq!(c.min_interval_ms, 60_000);
- assert_eq!(c.base_play_ms, 60_000);
- assert_eq!(c.reward_play_ms, 30_000);
- assert_eq!(c.max_reward_grants_per_session, 50);
- assert_eq!(c.first_session_play_ms, 60_000);
- // A sane config is returned unchanged.
- assert_eq!(
- clamp_ad_config(default_ad_config()).base_play_ms,
- default_ad_config().base_play_ms
- );
- }
-}
diff --git a/crates/rustynes-monetization/uniffi-bindgen.rs b/crates/rustynes-monetization/uniffi-bindgen.rs
deleted file mode 100644
index 2afa84b2..00000000
--- a/crates/rustynes-monetization/uniffi-bindgen.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-//! uniffi-bindgen.rs — in-crate binding generator.
-//!
-//! UniFFI's standalone `uniffi-bindgen` requires a nightly toolchain to discover the
-//! compiled library's metadata. The supported workaround on stable Rust is to expose
-//! the exact same entry point from inside the crate, which is what this binary does.
-//!
-//! The real entry point only exists when the `uniffi/cli` feature is on, which our
-//! crate's `cli` feature enables. The body is therefore `#[cfg]`-gated so that an
-//! ordinary build or `cargo test` (which compiles every target, including this bin)
-//! does not require the CLI dependencies.
-//!
-//! Run it (after building the native library) like so — see README for full commands:
-//!
-//! cargo run --features=cli --bin uniffi-bindgen -- generate \
-//! --library target//release/librustynes_monetization. \
-//! --language --out-dir