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 @@

- Build Status License: MIT OR Apache-2.0 Version Rust: 1.96
+ Build Status License: MIT OR Apache-2.0 Version Rust: 1.96
AccuracyCoin nestest Try in browser
Platform

@@ -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 -//! -//! `--library` mode reads the type definitions straight from the built artifact, so -//! the generated Kotlin/Swift can never fall out of sync with the Rust source. - -fn main() { - #[cfg(feature = "cli")] - uniffi::uniffi_bindgen_main(); - - #[cfg(not(feature = "cli"))] - eprintln!( - "uniffi-bindgen was built without the `cli` feature. \ - Re-run with: cargo run --features=cli --bin uniffi-bindgen -- " - ); -} diff --git a/crates/rustynes-monetization/uniffi.toml b/crates/rustynes-monetization/uniffi.toml deleted file mode 100644 index d236c81f..00000000 --- a/crates/rustynes-monetization/uniffi.toml +++ /dev/null @@ -1,16 +0,0 @@ -# uniffi.toml — controls how UniFFI names the generated bindings. -# -# Pinning these makes the import statements in the Kotlin and Swift shells stable and -# predictable (they are hand-written against these names in AdGate.kt / AdGate.swift). - -[bindings.kotlin] -# Generated Kotlin lands in com/doublegate/rustynes/monetization/ffi/rustynes_monetization.kt under the out-dir, so -# the shells `import com.doublegate.rustynes.monetization.ffi.AdPolicy`, etc. (package name kept stable across the -# crate rename so the Kotlin shells need no edit). -package_name = "com.doublegate.rustynes.monetization.ffi" - -[bindings.swift] -# Generated Swift exposes a module named `RustyNesMonetization` (NOT `RustyNesCore`, which -# would clash conceptually with the emulator's rustynes-core). The shells `import -# RustyNesMonetization`. Match this with cargo-swift's `--name RustyNesMonetization`. -module_name = "RustyNesMonetization" diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 48373442..520cee40 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -52,7 +52,6 @@ The core "spec" docs — kept in sync with the code in the same PR as a change. | [adr/](adr/) | Architecture Decision Records (Michael Nygard format), 0001–0025 — mapper dispatch, IRQ-timing coordination, save-state migration, VRC7 audio, DMC scheduler, TAS movie format, mapper tiering, the IPC / HD-Pack-Builder / i18n set (0016–0023), the mobile bridge + hybrid Android host (0024), and the `foss` / `play` flavor split (0025). | | [audit/](audit/) | Dated investigation + decision-rationale notes (the accuracy-program "why" history). Retains the engine-lineage version markers and dates. | | [release-notes/](release-notes/) | Per-version release notes for the engine-lineage line that produced v1.0.0 (`v2.0.0` … `v2.8.0`). Kept as lineage history; the current RustyNES release is **v1.8.8 "Atlas"** on the v1.0.0 production core (see the root `CHANGELOG.md` and [`STATUS.md`](STATUS.md)). | -| [monetization/](monetization/) | Mobile freemium monetization design + compliance set (ad-supported model: AppLovin MAX + RevenueCat, the $3.99 "Full Version / Remove Ads" unlock). The shared policy core is the `rustynes-monetization` crate. Reference docs; launch is the v2.1.0 joint mobile release. | | [tooling/](tooling/) | Oracle / cross-emulator tooling setup (Mesen2 trace, AccuracyCoin extraction). | | [user-guide/](user-guide/) | End-user docs — getting started, controls, configuration, debugger, save states/rewind, display/audio, file locations, compatibility, troubleshooting. | | [archive/legacy-v0.8-docs/features/](archive/legacy-v0.8-docs/features/) | Legacy v0.8 design docs per feature (debugger, expansion audio, RetroAchievements, rewind, TAS, video filters, WASM) — historical; the shipped features are documented in the v1.0.0 docs above. | diff --git a/docs/STATUS.md b/docs/STATUS.md index 7e922049..ba2c4ac1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,10 +1,22 @@ # RustyNES — Project Status Matrix -> **Current release: v2.2.5** (2026-08-03) — **"Colophon"**, a **provenance, +> **Current release: v2.2.6** (2026-08-04) — **"Almanac"**, a **de-monetization and +> provenance** release opening the **v2.2.6 → v2.3.0** NESdev-remediation line, with +> **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)**, +> nestest 0-diff, and the `#![no_std]` chip stack, save-state / TAS / netplay formats, +> and every golden vector are byte-identical by construction). It removes all planned +> monetization — **RustyNES is permanently open-source and income-free (ADR 0035)**: the +> `rustynes-monetization` crate, `docs/monetization/`, and the Android/iOS billing / ad / +> freemium / paywall layers are deleted; the native apps are kept as **free FOSS apps** +> (no ads, no tracking, no paid unlock), retaining the free Google-Play services and the +> `foss`/`play` split. It also discloses (per ADR 0030) that the PPU hybrid-address +> *timing* was calibrated to TriCNES (reproducing the Rad Racer artifact), flagged for a +> documentation-derived rework in v2.3.0. +> +> The prior release, **v2.2.5** (2026-08-03) — **"Colophon"**, a **provenance, > licensing, and documentation-integrity** release with **zero emulation-core -> behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)**, nestest 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). It reworded in-source +> behavior changes** (AccuracyCoin 141/141, nestest 0-diff, byte-identical to v2.2.4 by +> construction), reworded in-source > comments that had mischaracterized publicly-documented hardware-behavior > implementations as "ports of" copyleft emulators (Mesen2, puNES); rewrote > `NOTICE` to disclose the behavioral-oracle use of GPL emulators (no code @@ -529,9 +541,9 @@ > submission** (that is the future v2.1.0 joint launch); the **on-device closeout is a > maintainer / v2.0.9 step** — the actual Play Console listing upload, signing with the > real keystore, the internal/closed testing track, the crash-free-rate + ANR gate on -> real hardware, live monetization runtime verification, and the deferred per-feature -> gate migration (keep the existing `LicenseManager` freemium gates — do **not** rewire -> them to the monetization `feature_enabled` façade without on-device validation). See +> real hardware, and the deferred per-feature gate migration described in the historical +> `LicenseManager` / monetization-façade plan below (that monetization work was later +> removed entirely — RustyNES is permanently open-source and income-free, ADR 0035). See > `CHANGELOG.md` `[2.0.4]` + `to-dos/plans/v2.0.4-android-rc-plan.md`. > > **Earlier in the train: v2.0.3 "Harbor" ("Keel")** (2026-07-08) — the third release of the v2.0.x @@ -563,7 +575,9 @@ > `MonetizationGate` with AppLovin MAX + RevenueCat 8.10.0, `feature_enabled` gating, > run-out paywall + countdown, session/progress/offline-grace; a no-op `foss` twin so > the F-Droid artifact stays behaviour-identical; both flavors assemble, monetization -> behaviourally dormant pending v2.0.9 on-device verify) and a **host-localizable +> behaviourally dormant pending v2.0.9 on-device verify — this monetization glue was +> later removed entirely; RustyNES is permanently open-source and income-free, ADR +> 0035) and a **host-localizable > mobile bridge-warning** API (`rustynes-mobile` `HostWarning` enum + > `drain_warning_codes()`; `drain_warnings()` preserved). No store submission (that is > the future v2.1.0 joint launch); Android continues as GitHub-sideload. See @@ -625,9 +639,10 @@ > moved to `playImplementation`, the six Google-touching glue classes moved to `src/play/` > with **no-op façades in `src/foss/`** (so `foss` links zero Google SDKs), the Play > manifest meta-data moved to `src/play/AndroidManifest.xml`, and the -> `installDebug`→`installFossDebug` alias. Ad / RevenueCat glue stays dormant; the ad -> wiring, on-device dual-flavor verification, and F-Droid submission are the v2.1.0 step. -> See `docs/android.md` + `to-dos/v1.8.x-on-device-verification.md`. android.yml CI (the +> `installDebug`→`installFossDebug` alias. Ad / RevenueCat glue stayed dormant and was +> later removed entirely (ADR 0035 — RustyNES is permanently open-source and +> income-free); on-device dual-flavor verification and F-Droid submission remain a +> forward step. See `docs/android.md` + `to-dos/v1.8.x-on-device-verification.md`. android.yml CI (the > NDK cross-build + both-flavor Gradle package) is the compile gate for this change. > > **The preceding release: v2.0.0 "Timebase"** (2026-07-03) — the **one-clock, @@ -706,8 +721,9 @@ > P1–P4** + remap, and optional haptics. The preceding **v1.9.1 "Patch"** — a > TestFlight **build-refresh > cron** (builds expire 90 days after upload) + a **dormant freemium-gate scaffold** -> (`ios/RustyNES/Entitlements.swift`, fully unlocked through v1.9.x; the -> present-but-inert seam the v2.1.0 monetization wiring drops into). The preceding +> (`ios/RustyNES/Entitlements.swift`, fully unlocked through v1.9.x — this scaffold +> and the monetization wiring it anticipated were later removed entirely; RustyNES +> is permanently open-source and income-free, ADR 0035). The preceding > **v1.9.0 "Sunrise"** (2026-06-25) — the first **iOS / iPadOS** > release: the foundation slice of the v1.9.0 → v1.9.9 TestFlight train (mirroring > the Android v1.8.0 → v1.8.9 arc), a native **SwiftUI** shell over the @@ -731,15 +747,18 @@ > HD-pack form, including `` and the CHR-RAM tileData-hash conditions), a > Game Genie database, NSF waveform-viz depth, mapper breadth **168 → 172 families** > (m193/204/221/299 + ~35 UNIF board aliases), plus the 13-PR Dependabot -> consolidation and the dormant `rustynes-monetization` build-out. All additive / -> off-by-default, so the shipped / native / `no_std` / wasm core stays byte-identical +> consolidation and the dormant `rustynes-monetization` build-out (that crate was +> later removed entirely; RustyNES is permanently open-source and income-free, ADR +> 0035). All additive / off-by-default, so the shipped / native / `no_std` / wasm core stays byte-identical > and AccuracyCoin holds 100% (139/139). The preceding **v1.8.8 "Atlas"** (2026-06-20) > was the Google-Play-launch + > Android-native-excellence increment on the first **platform** (not accuracy) release > line **v1.8.0 "Android"**: a complete Android app (shared `rustynes-mobile` UniFFI > bridge + `rustynes-android` platform crate + a Jetpack Compose shell; audio / > touch+gamepad input / save-states+SRAM+recent-ROMs / pause-FF-mute / -> foldable+immersive UI; a freemium $2.99 Play unlock + an 8-minute demo — interim +> foldable+immersive UI; a freemium $2.99 Play unlock + an 8-minute demo (this +> monetization model was later removed entirely; RustyNES is permanently +> open-source and income-free, ADR 0035) — interim > builds are full-featured via `PLAY_BUILD=false`), verified on a Galaxy Z Fold 7 > (SMB / Zelda / AccuracyCoin). **v1.8.8** modernizes the toolchain to the **Android 16 > / API 36 target mandate** (AGP 9.2.1 / Gradle 9.4.1 / compileSdk 37 / targetSdk 36, @@ -847,7 +866,8 @@ lifecycle), **v1.9.3 "Workshop-lite"** the settings / save-state-slots / onboard release, **v1.9.2 "Input"** the input release (multi-touch pad, Android-parity NES-001 render + glyph-identical fonts, GameController P1–P4 + remap, haptics), **v1.9.1 "Patch"** an -iOS-line patch (TestFlight build-refresh cron + a dormant freemium-gate scaffold), and +iOS-line patch (TestFlight build-refresh cron + a dormant freemium-gate scaffold, +later removed entirely per ADR 0035), and **v1.9.0 "Sunrise"** the first iOS / iPadOS release (a native SwiftUI shell + the new `rustynes-ios` Metal/CoreAudio shim); on the **v1.8.x "Android"** line **v1.8.9 "Backlog"** was the @@ -1794,7 +1814,9 @@ the blockquote at the top + `CHANGELOG.md` `[1.8.9]` — plus the 13-PR Dependab consolidation (jni 0.21 → 0.22, zip 2 → 8.6, naga 25 → 29, sha1 / md-5 0.10 → 0.11, pollster 0.3 → 0.4, android_logger 0.14 → 0.15, lz4_flex 0.11 → 0.13, plus the GitHub Actions bumps) and the **monetization build-out** (the new, dormant -`rustynes-monetization` crate — the shared ad-supported / freemium policy core); the +`rustynes-monetization` crate — the shared ad-supported / freemium policy core; this +crate and all monetization work were later removed entirely — RustyNES is +permanently open-source and income-free, ADR 0035); the emulation core stays byte-identical and AccuracyCoin holds 100% (139/139). The table below is the **engine-lineage** version history — the internal engine line whose increments produced the v1.0.0 technology. Its @@ -1859,13 +1881,17 @@ own semantic-version line starting at **v1.0.0**. > `DualSystem` libretro presentation, and v2.2.0 closed the run (the netplay matchmaking / lobby > stack + the FDS medium model + a peripherals & quality/security pass — cargo-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 **JOINT mobile store launch** (Google Play + Apple App -> Store + AltStore PAL + F-Droid), and with it the `rustynes-monetization` activation, is the -> future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets). The monetization model -> is ad-supported with a **$3.99** premium unlock (AppLovin MAX + RevenueCat, a reward-ad -> +11-minute × 2 demo extension, 6 premium features) under a **`foss` / `play` flavor split** -> (ADR 0025); the shared policy core is the `rustynes-monetization` crate. See -> `to-dos/plans/v2.0.x-mobile-finalization-plan.md`. +> NTSC-byte-identical, AccuracyCoin 141/141 throughout. RustyNES is permanently +> open-source and income-free (ADR 0035): all planned monetization is removed and the +> mobile apps ship as **free FOSS apps** with no ads, no tracking, and no paid unlock. +> The freed **v2.3.0** slot is repurposed as the accuracy capstone of the v2.2.6 → +> v2.3.0 line (NOT a store launch — the earlier v2.1.0 / v2.2.0 / v2.3.0 store-launch +> targets are retired). A **free** store listing (Google Play / Apple App Store / +> AltStore PAL / F-Droid) is a possible **later**, unversioned step with no monetization +> attached. The **`foss` / `play` flavor split** (ADR 0025) is kept, but now +> distinguishes only the pure-AOSP build from optional free Google Play services +> (achievements, Cast, Integrity, in-app update, cloud save) — no billing, no ad SDK. +> See `to-dos/plans/v2.0.x-mobile-finalization-plan.md`. | Version | Status | Bar | |---------|--------|-----| diff --git a/docs/adr/0025-foss-play-android-flavor-split.md b/docs/adr/0025-foss-play-android-flavor-split.md index b7aaf25a..57b36baf 100644 --- a/docs/adr/0025-foss-play-android-flavor-split.md +++ b/docs/adr/0025-foss-play-android-flavor-split.md @@ -4,11 +4,18 @@ Date: 2026-06-23 ## Status -Accepted (planned for **v2.1.0** — the joint mobile store launch). The decision is -locked now; the implementation is a v2.1.0 deliverable (see -`to-dos/plans/v2.0.x-mobile-finalization-plan.md`). Until then the Android app stays -single-flavor and the proprietary SDKs ride along dormant behind their `BuildConfig` -flags, as today. +**Superseded by [ADR 0035](0035-rustynes-is-permanently-non-commercial.md) (v2.2.6).** +RustyNES is now permanently open-source and income-free, so the monetization portion of +this ADR — the `play` flavor's Play Billing freemium unlock (`LicenseManager`) and its +AppLovin MAX / RevenueCat ad layer — is removed. The `foss`/`play` **flavor split itself +is retained** (it still distinguishes the pure-AOSP F-Droid build from the build carrying +the *free* Google-Play services below), but every ad/billing element it once gated is +gone. Read the rest of this ADR as historical context for the flavor mechanism, not as a +description of a shipping monetization plan. + +The original status (now historical): Accepted (planned for the joint mobile store +launch); the Android app stayed single-flavor with the proprietary SDKs dormant behind +`BuildConfig` flags until then. ## Context diff --git a/docs/adr/0027-ios-distribution-and-app-store-compliance.md b/docs/adr/0027-ios-distribution-and-app-store-compliance.md index 31ee351a..9c88b9ad 100644 --- a/docs/adr/0027-ios-distribution-and-app-store-compliance.md +++ b/docs/adr/0027-ios-distribution-and-app-store-compliance.md @@ -4,8 +4,16 @@ Date: 2026-06-25 ## Status -Accepted (the v1.9.0 → v1.9.9 iOS train + the v2.1.0 joint launch). Extends ADR -0025 (the Android `foss` / `play` flavor split) to iOS. +**Amended by [ADR 0035](0035-rustynes-is-permanently-non-commercial.md) (v2.2.6).** +RustyNES is now permanently open-source and income-free: the **monetization portions of +this ADR are removed** — the ad-bearing App-Store flavor, the App Tracking Transparency +(ATT) prompt (which existed only for the ad SDK), and the StoreKit 2 "$3.99 / Remove Ads" +unlock. The **§4.7 ROM-import / user-content-only compliance rules remain in force** — +they are valid and necessary for a *free* NES-emulator app on the App Store, independent +of any monetization. Read the monetization-wiring passages below as historical context. + +The original status (now historical): Accepted (the v1.9.0 → v1.9.9 iOS train + the joint +launch). Extends ADR 0025 (the Android `foss` / `play` flavor split) to iOS. ## Context diff --git a/docs/adr/0035-rustynes-is-permanently-non-commercial.md b/docs/adr/0035-rustynes-is-permanently-non-commercial.md new file mode 100644 index 00000000..72af2e35 --- /dev/null +++ b/docs/adr/0035-rustynes-is-permanently-non-commercial.md @@ -0,0 +1,70 @@ +# 35. RustyNES is permanently non-commercial (no monetization) + +Date: 2026-08-04 + +## Status + +Accepted. **Supersedes** the monetization portions of +[ADR 0025](0025-foss-play-android-flavor-split.md) (the `play` ad/billing layer) and +**amends** [ADR 0027](0027-ios-distribution-and-app-store-compliance.md) (removes the +ad-bearing App-Store flavor, ATT, and StoreKit unlock; keeps the §4.7 ROM-compliance +rules, which remain valid for a free app). + +## Context + +Earlier planning (ADRs 0024–0027, the `to-dos/plans/` mobile train, and +`docs/monetization/`) laid out an ad-supported freemium model for the Android and iOS +apps: a `$3.99` one-time "Full Version / Remove Ads" unlock via **RevenueCat** + Google +**Play Billing** / Apple **StoreKit 2**, interstitial and rewarded ads via **AppLovin +MAX**, an 8-minute demo session with a rewarded-ad extension gate, and six "premium" +features — all staged in a dormant `crates/rustynes-monetization` crate wired into the +Android build, to be "activated" at a future joint store launch (variously v2.1.0, then +v2.3.0). + +The maintainer has decided this is the wrong direction for the project. RustyNES is a +learning-driven hobby project, its emulation is well served for end users by the +in-RetroArch **libretro core** and by mainstream emulators, and a paid layer is at odds +with how the project is built and shared. It also complicates the honesty/provenance +posture the project cares about (see ADR 0030 and `docs/originality-and-provenance.md`). + +## Decision + +**RustyNES is and will remain open-source and income/profit-free, permanently.** There +is no monetization anywhere in the project: no ads, no tracking-for-revenue, no +freemium, no demo/time gate, no in-app purchase, and no paid unlock — in any build or +platform. The native Android and iOS apps are **kept as free FOSS apps** (fully +functional, no ads, no tracking); only the paid/ad layer is removed. + +Concretely, in v2.2.6 "Almanac": + +- The `crates/rustynes-monetization` crate and `docs/monetization/` are deleted and the + workspace member removed. No emulation-core crate ever depended on it, so the + deterministic core is untouched (AccuracyCoin 141/141 by construction). +- The Android paid layer is removed: `Billing.kt`, `MonetizationGate.kt`, the + `monetization/` package (AppLovin/RevenueCat gates), paywall/demo strings, AdMob/ + AppLovin manifest entries, and the billing/ad Gradle deps + BuildConfig keys + the + monetization cargo/uniffi tasks. `MainActivity` drops the demo/paywall/`unlocked` + gating so every feature is unconditionally available. The `play`/`foss` flavor split + and the free Google-Play *services* (Play Games achievements, Cast, Play Integrity, + in-app update, cloud save) are retained. +- The iOS paid layer is removed: the StoreKit `StoreManager` (`Entitlements.swift`), the + `appStore` monetization build channel, and billing entitlements/`project.yml` linkage. +- The ROADMAP / `docs/STATUS.md` / version plans are reframed: RustyNES is OSS/income-free + forever; the freed v2.3.0 slot is repurposed for accuracy/fidelity work. A **free** app + distribution (GitHub Releases sideload today; optionally a free F-Droid / App Store + listing later) may still happen — with no monetization attached. + +## Consequences + +- **Positive:** the project's stated nature and its shipped artifacts finally agree; the + provenance/honesty posture is simpler (nothing to reconcile against a paid product); + and no code, credentials, or CI paths carry ad-SDK or billing dependencies. The + emulation core, save-state/movie formats, and every golden vector are unchanged + (AccuracyCoin 141/141 by construction — v2.2.6 is a doc/scaffold/app-shell change). +- **Negative / carried:** ADR 0025 is superseded and ADR 0027 amended; historical + CHANGELOG entries that shipped with monetization scaffolding remain as history (the + scaffolding was dormant and never activated). `rustynes-monetization` is deleted, not + merely disabled, so re-introducing monetization would be a new, deliberate decision + reversing this ADR — which is the intended bar. +- **Follow-up:** `docs/originality-and-provenance.md` and `NOTICE` disclose the TriCNES + behavioral-calibration caveat (see ADR 0030) as part of the same honesty pass. diff --git a/docs/android.md b/docs/android.md index dda61c85..dc187e3b 100644 --- a/docs/android.md +++ b/docs/android.md @@ -4,8 +4,9 @@ > deferred to v2.1.0.** The v1.8.x line keeps shipping as **GitHub-sideload** builds, > but the Play production launch is held until after **v2.0.0 "Timebase"** — the > Android app is finalized across **v2.0.1–v2.0.4** on the v2.0.0 core and launched -> **jointly with iOS at v2.1.0**. The monetization / Play-Store material in this doc -> describes that eventual v2.1.0 launch; it is not a near-term step. See +> **jointly with iOS at v2.1.0**. RustyNES is permanently open-source and income-free +> (ADR 0035): the Play-Store launch material in this doc describes a free-app launch, +> not a near-term step. See > [`to-dos/plans/v2.0.x-mobile-finalization-plan.md`](../to-dos/plans/v2.0.x-mobile-finalization-plan.md). > > **Status: v1.8.0 "Android" — a working emulator, verified on hardware.** The @@ -82,7 +83,10 @@ and rendering decisions (UniFFI bridge plus the hybrid wgpu/Compose host). Android cross-play stays valid. - **No commercial ROMs are ever bundled.** ROM bytes come exclusively from the Storage Access Framework document picker (no path, raw bytes → `Nes::from_rom`). - The app declares no storage/network permissions and collects nothing. + The **`foss`** build declares no storage/network permissions and collects nothing; + the **`play`** build adds only the optional free Google Play services (Play Games, + Cast, Play Integrity, in-app update, cloud save), which use the network and + Google-account data — with no ads, no tracking, and no billing. ## Prerequisites @@ -136,8 +140,8 @@ gradle wrapper --gradle-version 8.11.1 # first time, materialises ./gradlew > **`foss`** flavor (default) and a **`play`** flavor, so AGP names the variant tasks > per-flavor (`assembleFossDebug`, `bundlePlayRelease`, …). The bare `installDebug` is kept as > an alias to `installFossDebug` so the existing dev/CI command is undisturbed; the aggregate -> `assembleDebug` / `bundleRelease` anchors still fan out to both flavors. See "Distribution -> flavors" under Monetization below. +> `assembleDebug` / `bundleRelease` anchors still fan out to both flavors. See "No +> monetization (ADR 0035)" below. `minSdk 26` (AAudio floor), `targetSdk 35` (Play mandate), `compileSdk 35`. Ship ABI is `arm64-v8a`; `x86_64` is included for the emulator. The release build runs @@ -156,64 +160,25 @@ A guaranteed sideload / F-Droid + GitHub-Releases channel is maintained so the project never depends solely on Play (also the home for the optional egui-debugger power-user build). -## Monetization (freemium $2.99 unlock) - -> **Model under revision (2026-06-23).** An **ad-supported** freemium model — **AppLovin -> MAX** ads on the free tier removed by a **RevenueCat** `premium` entitlement, a one-time -> **"Full Version / Remove Ads" ($3.99)** unlock — has been harvested into the repo as the -> chosen **v2.1.0-launch** monetization, superseding the $2.99-no-ads model described below. -> The shared policy core is `crates/rustynes-monetization` (`AdPolicy`) and the full design -> set is [`monetization/`](monetization/) (start at its `README.md`). The $2.99 -> `LicenseManager` below remains the **current sideload-build behavior**; the freemium / -> ad layer ships **default-off behind `PLAY_BUILD`** and is flipped on at the v2.1.0 joint -> launch. The free-tier persistence gates (save-states / resume / SRAM) are **identical** -> across both models; the change is ads + the $3.99 price + the RevenueCat provider. -> -> **`foss` / `play` flavor split (ADR 0025) — structural start landed in v2.0.1.** The build -> splits into a **`foss`** flavor (default — no Google SDKs, no ads, no tracking; the -> **F-Droid** + GitHub-sideload artifact) and a **`play`** flavor (all the proprietary SDKs — -> Billing, Cast, Play Games, Integrity, update/review **+ AppLovin + RevenueCat** at v2.1.0 — -> for **Google Play**). F-Droid requires a Google-/ad-free build, so this is the only way to -> reach that channel. **v2.0.1 landed the structural split:** the `distribution` flavor -> dimension, per-flavor `PLAY_BUILD`, every proprietary SDK moved to `playImplementation`, the -> six Google-touching glue classes moved to `src/play/` with **no-op façades in `src/foss/`** -> (so `foss` links zero Google SDKs and `MainActivity` calls a flavor-neutral façade), the -> Cast/Play-Games manifest meta-data moved to `src/play/AndroidManifest.xml`, and the -> `installDebug`→`installFossDebug` alias. The **ad / RevenueCat glue stays dormant** — that -> wiring, plus the on-device verification of both flavors and the F-Droid submission, is the -> v2.1.0 step. Already wired in v1.8.9 (dormant): the `rustynes-monetization` crate's `.so` + -> UniFFI bindings (our own clean Rust core). See -> [`../to-dos/plans/v2.0.x-mobile-finalization-plan.md`](../to-dos/plans/v2.0.x-mobile-finalization-plan.md). - -The app is a **free download** with a single **one-time, non-consumable in-app -purchase** — "Full Unlock", product id `full_unlock`, **$2.99 USD** — via Play -Billing (Workstream M, `LicenseManager`). The free tier is a time-limited demo: - -- a **10-minute play session per launch** (60 s in debug builds for testing), -- **save-states disabled** (Save/Load hidden), -- **save-on-background / auto-resume disabled**, and -- **on-cart battery-backed SRAM never persisted** across a close. - -Everything else — full emulation accuracy, video, audio, input, pause/fast-forward, -the ROM library — is identical to the paid tier. The emulated framebuffer/audio is -byte-identical in demo and paid; every gate is host policy over persistence + a -session clock, so the core, the `.rns` format, and AccuracyCoin are untouched. - -**Maintainer-manual Play Console setup (can't be CI-self-certified):** - -1. In the Play Console → Monetize → Products → **In-app products**, create a - **managed product** with id **`full_unlock`**, set its price to **$2.99**, and - activate it. -2. Upload a build to a **closed/internal test track** and add a - **license-tested** account — the Play purchase flow only runs for an app - distributed through Play, not a sideloaded APK. -3. Verify: buy → the demo timer + Unlock button disappear and Save/Load appear; - reinstall → "Restore purchase" (or the automatic `queryPurchasesAsync` on - launch) re-grants the entitlement (a non-consumable is owned forever). - -A `BuildConfig.DEBUG`-only `DBG:unlock` toggle in the control bar exercises the -gating + timer + unlock UI on a sideloaded build without Play (the gating logic is -verified that way; only the real billing transaction needs the Console). +## No monetization (ADR 0035) + +RustyNES is permanently open-source and income-free — see +[ADR 0035](adr/0035-rustynes-is-permanently-non-commercial.md). The Android app is a +**free download** with every feature unconditionally available: no ads, no tracking, +no demo/time gate, no in-app purchase, and no paid unlock. There is no `LicenseManager` +paywall, no `full_unlock` product, and no freemium ad layer — the app that was +previously planned as an ad-supported / one-time-unlock freemium model (an earlier +`docs/monetization/` design set and the `rustynes-monetization` crate) was never +shipped to a store, and that plan and crate have since been removed entirely. + +**`foss` / `play` flavor split (ADR 0025, amended by ADR 0035).** The build still +splits into a **`foss`** flavor (default — no Google SDKs; the **F-Droid** + +GitHub-sideload artifact) and a **`play`** flavor (optional free Google Play +services — Play Games achievements, Cast, Play Integrity, in-app update, cloud +save). Both flavors are free with identical gameplay features; the `play` flavor +carries no billing or ad SDKs. See +[`../to-dos/plans/v2.0.x-mobile-finalization-plan.md`](../to-dos/plans/v2.0.x-mobile-finalization-plan.md) +for the historical planning record. ## CI @@ -233,7 +198,7 @@ alignment** on the shipped arm64 `.so`, and best-effort-bundles the AAB. It is | **E** Save-states / SRAM / auto-resume / persistable ROM library | **Done** — save/load on-device | | **F** QoL (pause / fast-forward / mute) + responsive/foldable/immersive UI | **Done** — on-device | | **G** Play packaging (release AAB, signing config, distribution) | Release AAB verified (R8, arm64-only); signing is maintainer-manual | -| **M** Freemium $2.99 unlock + 10-min demo gating | **Done** — gating/timer/overlay on-device; Play product maintainer-manual | +| **M** Freemium $2.99 unlock + 10-min demo gating | Removed entirely (ADR 0035) — RustyNES is permanently open-source and income-free; every feature ships unconditionally available | | **B** wgpu `SurfaceView` + surface-loss lifecycle | **Next increment** (Bitmap blit ships now) | | **F** shaders / palettes / per-game DB / TAS UI | **Next increment** (depends on B for shaders) | diff --git a/docs/architecture.md b/docs/architecture.md index 634d0164..69f69481 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Rejected: catch-up sacrifices sub-cycle PPU edge cases that the project exists t The `no_std`-friendly chip stack + the glue core form the deterministic emulation foundation; the remaining crates are additive host/platform/tooling layers that never perturb the core's per-frame output (the determinism contract -below). The current workspace is 16 crates: +below). The current workspace is 15 crates: ```text crates/ @@ -65,8 +65,7 @@ crates/ ├── rustynes-gfx-shaders/ # Shared WGSL presentation shaders (desktop + Android, no copy-paste drift) (v1.8.4). ├── rustynes-hdpack/ # HD-pack loader + compositor + HD audio, extracted for the mobile bridge (v1.8.5). ├── rustynes-mobile/ # Shared UniFFI mobile bridge (consumed by rustynes-android / a future iOS crate) (v1.8.0). -├── rustynes-android/ # Android JNI platform glue (cfg-gated) (v1.8.0). -└── rustynes-monetization/ # Mobile-only ad/unlock policy (never touches the deterministic core). +└── rustynes-android/ # Android JNI platform glue (cfg-gated) (v1.8.0). ``` `rustynes-core` re-exports the public types from `rustynes-cpu`, `rustynes-ppu`, `rustynes-apu`, `rustynes-mappers` so that consumers (frontend, test harness, embedders) need only depend on `rustynes-core`. diff --git a/docs/build-and-tooling.md b/docs/build-and-tooling.md index 69aaae14..c7ad8365 100644 --- a/docs/build-and-tooling.md +++ b/docs/build-and-tooling.md @@ -7,7 +7,7 @@ - **Rust edition**: 2024. - **MSRV (minimum supported Rust version)**: 1.96.0. Pinned via `rust-toolchain.toml`. (Bumped from 1.86 in v1.3.0 "Bedrock" to unblock the edition-2024 + egui 0.34.3 / wgpu 29 / rfd 0.17.2 dependency tier.) - **Channel**: the pinned `1.96.0` stable release — *not* a floating `stable`. `rust-toolchain.toml` is the single source of truth: every GitHub Actions job resolves its toolchain from that file (`.github/actions/rust-setup` parses the `channel` and fails closed if it cannot), the libretro buildbot builds all ten of its jobs on it, and local builds pick it up automatically as a directory override. There is no `toolchain:` version literal anywhere in `.github/`, so bumping the pin is a one-line edit here — but read the `-C ar` warning in `rust-toolchain.toml` before bumping to 1.97 or newer. -- **Nightly** is used for exactly two things, both outside CI and neither a gate: `cargo fuzz`, which requires it for the sanitizer flags it threads through `rustc` (`cargo +nightly fuzz run ` — see `fuzz/README.md`), and the dormant `rustynes-monetization` crate's standalone `uniffi-bindgen` helper, which needs it for metadata discovery. No build, test, lint, docs, release, or packaging path uses nightly. +- **Nightly** is used for exactly one thing, outside CI and not a gate: `cargo fuzz`, which requires it for the sanitizer flags it threads through `rustc` (`cargo +nightly fuzz run ` — see `fuzz/README.md`). No build, test, lint, docs, release, or packaging path uses nightly. - **Targets supported**: `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Tier 2: `aarch64-unknown-linux-gnu`. Cross-compile targets declared in `rust-toolchain.toml` (auto-installed): `thumbv7em-none-eabihf` (the `no_std` chip-stack gate) and `wasm32-unknown-unknown` (browser). Android arm64/arm/x86_64 via `cargo ndk` (see `docs/android.md`). The `x86_64-apple-darwin` release target was retired (ADR 0009). ## Workspace layout @@ -29,7 +29,6 @@ crates/ ├── rustynes-hdpack/ # HD-pack loader + compositor + HD audio ├── rustynes-mobile/ # shared mobile UniFFI bridge (Android + future iOS) ├── rustynes-android/ # cfg-gated Android JNI / wgpu-SurfaceView / AAudio glue -├── rustynes-monetization/ # mobile-only UniFFI ad-policy crate (never touches the core) ├── rustynes-frontend/ # rustynes binary (winit + wgpu + cpal + egui) └── rustynes-test-harness/ # test ROM runner, golden log compare tests/ # workspace-level integration tests @@ -107,7 +106,7 @@ See `Cargo.toml` files per crate. Key choices: - **`winit`** + **`wgpu`** 29 + **`naga`** 29 + **`cpal`** + **`egui`** 0.34.3 + **`rfd`** 0.17.2 + **`gilrs`** for the frontend. - **`mlua`** 0.11 (vendored Lua 5.4, native scripting) / **`piccolo`** 0.3.3 (pure-Rust wasm Lua backend, ADR 0012) — mutually exclusive. - **`zip`** 8, **`sha1`** / **`md-5`** 0.11 (movie / interchange hashing). -- Android: **`jni`** 0.22, **`pollster`** 0.4, **`android_logger`** 0.15, **`uniffi`** 0.31 (the `rustynes-mobile` / `rustynes-android` / `rustynes-monetization` crates). +- Android: **`jni`** 0.22, **`pollster`** 0.4, **`android_logger`** 0.15, **`uniffi`** 0.31 (the `rustynes-mobile` / `rustynes-android` crates). - **`criterion`** + **`proptest`** + **`insta`** as dev-deps. No runtime async (`tokio`/`async-std`) in the emulator core — it is synchronous; the cpal callback runs on its own thread without async. (Netplay signaling uses a small blocking `tungstenite` worker in the frontend, outside the deterministic core.) @@ -158,7 +157,7 @@ cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-feature entirely on documentation-only pushes (`paths-ignore`), and cancels superseded PR runs (`concurrency`). - `workflows/android.yml` — `cargo ndk` cross-compiles `rustynes-mobile` + - `rustynes-android` + `rustynes-monetization`, generates the UniFFI Kotlin + `rustynes-android`, generates the UniFFI Kotlin bindings, and runs the Gradle build (AGP 9.2.1 / Gradle 9.4.1 / compileSdk 37 / targetSdk 36 / minSdk 26). See `docs/android.md`. - `workflows/release.yml` — tag-triggered (`v*`), builds the per-platform diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index ea0cadf3..318dbe93 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -229,9 +229,9 @@ CI deploys the `wasm-winit` build to GitHub Pages ## Android Build The Android app cross-compiles the `rustynes-mobile` UniFFI bridge + -`rustynes-android` JNI glue + `rustynes-monetization` ad-policy crate via -`cargo ndk`, then builds the Jetpack Compose shell with Gradle (AGP 9.2.1 / -Gradle 9.4.1 / compileSdk 37 / targetSdk 36 / minSdk 26). See +`rustynes-android` JNI glue via `cargo ndk`, then builds the Jetpack Compose +shell with Gradle (AGP 9.2.1 / Gradle 9.4.1 / compileSdk 37 / targetSdk 36 / +minSdk 26). See [`../android.md`](../android.md) for the full setup. --- diff --git a/docs/ios-v1.9.9-readiness.md b/docs/ios-v1.9.9-readiness.md index f6f3e021..bf71e1eb 100644 --- a/docs/ios-v1.9.9-readiness.md +++ b/docs/ios-v1.9.9-readiness.md @@ -125,10 +125,12 @@ save-states + the four slots + CloudKit-synced states, `.rnm` TAS movies / impor cross-platform save + netplay parity must be re-validated on the new core. iOS host re-port lands v2.0.5–v2.0.8; dual-app readiness v2.0.9. -**Distribution / monetization (v2.1.0, joint with Android):** launch the App-Store -channel plus AltStore PAL, and wire `Entitlements.swift`'s dormant `StoreManager` to -`rustynes-monetization` (StoreKit 2 + RevenueCat + AppLovin MAX, ATT). Dormant / -fully-unlocked through v1.9.x. +**Distribution (v2.1.0, joint with Android):** launch the App-Store channel plus +AltStore PAL as a free app. RustyNES is permanently open-source and income-free (ADR +0035): `Entitlements.swift`'s dormant `StoreManager`, the `rustynes-monetization` +crate it anticipated wiring to (StoreKit 2 + RevenueCat + AppLovin MAX, ATT), and the +`appStore` monetization build channel have since been removed entirely — no +entitlement gating of any kind, on any build, through this release or beyond. **Maintainer-manual (not CI-certifiable):** Apple Developer account + bundle ID, fastlane match signing + App Store Connect API key, app icon / launch art, and the full on-device diff --git a/docs/ios.md b/docs/ios.md index 4a397284..fa1d67d7 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -204,11 +204,12 @@ a `schedule:` cron (the 1st of every other month, ~60-day cadence) re-builds + re-uploads so external testers don't lapse — TestFlight builds expire 90 days after upload. -A **dormant freemium gate** (`ios/RustyNES/Entitlements.swift`, v1.9.1) is wired -app-wide but fully unlocked through the v1.9.x train; it is the present-but-inert -seam the v2.1.0 launch points at the shared `rustynes-monetization` crate (the full -StoreKit 2 / RevenueCat scaffolding lands at v1.9.8 "Horizon"). Entitlement state -never reaches the deterministic core. +A dormant freemium gate (`ios/RustyNES/Entitlements.swift`, added v1.9.1) was wired +app-wide but fully unlocked through the v1.9.x train. RustyNES is permanently +open-source and income-free (ADR 0035): that gate, the StoreKit 2 / RevenueCat +scaffolding it anticipated, and the `appStore` monetization build channel have +since been removed entirely — the app ships fully unlocked with no entitlement +state at all. ## App Store posture (§4.7) — feasible and precedented diff --git a/docs/monetization/AGENT-GUIDANCE.md b/docs/monetization/AGENT-GUIDANCE.md deleted file mode 100644 index c3e208f8..00000000 --- a/docs/monetization/AGENT-GUIDANCE.md +++ /dev/null @@ -1,116 +0,0 @@ -# CLAUDE.md — RustyNES Mobile Monetization - -Working-memory entry point for Claude Code. Read this first, then the two source docs -below before writing code. - -## Read these first -0. **`docs/rustynes-integration.md`** — **read before anything else.** Maps this layer onto the - real RustyNES repo (the Compose + wgpu-`SurfaceView` hybrid app, the shared `rustynes-mobile` - bridge + `rustynes-android` glue, the determinism boundary, the NDK/`cargo-ndk`/AAB toolchain, - and the **v1.8.8** Play debut). Note: the maintainer has chosen the **ad-supported** RevenueCat - + AppLovin model (this repo) as the **primary** path — a deliberate override of the v1.8.0 - plan's ad-free default — with a one-time **"Full Version / Remove Ads" ($3.99)** unlock. -1. **`docs/implementation-brief.md`** — decisions, provider links, store-submission - blockers, testing, enhancements, open decisions. The *why* and the *compliance*. -2. **`docs/build-and-bindings.md`** — exact build commands (cargo-ndk / cargo-swift / - uniffi-bindgen) and the generated FFI API table. The *how*. -3. **`docs/pre-implementation-addendum.md`** — ad-placement strategy for an emulator, - ad-content scoping, emulator-specific performance items, and the recommended baseline ad - config. The *resolve-before-building* layer; read before wiring ad behavior. -4. **`docs/recommendations.md`** — considerations & suggestions beyond the baseline: - free-tier design holes to close, engineering robustness, and store/product polish. -5. **`docs/platform-setup-runbook.md`** — the human account/dashboard setup (stores, - RevenueCat, AppLovin, mediation networks). Not your job to execute, but it defines the - keys, product ids, and the `premium` entitlement the code depends on. -6. **`docs/README.md`** — index and reading order for the whole doc set. - -If any path differs in this repo, locate it before proceeding; do not reconstruct content -from memory. - -## What this is -Freemium for the RustyNES mobile ports: **AppLovin MAX** ads on the free tier, removed by a -**RevenueCat** `premium` entitlement (a one-time **"Full Version / Remove Ads"** purchase, -**$3.99**). The free tier pairs interstitials at natural breaks with rewarded ads that extend an -8-minute play session (+11 min each, capped at 2 grants → 30 min); premium removes ads and the -timer and unlocks the persistence features. All monetization *logic* lives in one Rust object -(`AdPolicy`) shared to Android (Kotlin) and iOS (Swift) via **UniFFI**. The mobile app itself is a -Compose + wgpu-`SurfaceView` hybrid over the shared `rustynes-mobile` bridge (see the integration -doc). Ship **Android first (v1.8.0; Play debut v1.8.8), iOS at v1.9.0**. - -## Build order -1. Fold `rustynes-monetization` (or its `monetization` module) into the existing Cargo workspace — - **one FFI crate, one `uniffi::setup_scaffolding!()`**. `cargo test` must stay green. -2. Android native: `cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 -o android/src/main/jniLibs build --release`. -3. Android bindings: `cargo run --features=cli --bin uniffi-bindgen -- generate --library <…librustynes_monetization.so> --language kotlin --out-dir android/src/main/java`. -4. Wire Android shells (`RustyNesApp.kt`, `Billing.kt`, `AdGate.kt`); inject keys via `gradle.properties`. -5. Configure RevenueCat (`premium` entitlement + offering) and AppLovin (ad units + the recommended networks below) dashboards. -6. Android compliance (§6–§7 of the brief); ship to internal testing. -7. iOS: `cargo swift package --platforms ios --name RustyNesMonetization --release`; wire Swift shells; iOS compliance; TestFlight → App Store. - -## Invariants — do not break -- **Single source of truth:** premium status flows into the core only through - `AdPolicy.set_premium(bool)`, fed from RevenueCat `entitlements["premium"].isActive` — no - second premium flag. The one sanctioned extra caller is a **debug-only tester override** - (`Billing.kt`/`Billing.swift`, OR-ed into the same `set_premium` path) for local QA; it is - compiled-inert in release, including the closed-test track. Closed-test testers are unlocked - via a RevenueCat promotional grant or Play license testing — see brief §9a / runbook §5a. -- **All ad/feature logic stays in the Rust core.** Shells only plumb SDKs. Adding cadence or - paywall logic in Kotlin/Swift defeats the cross-platform guarantee — put it in `monetization.rs`. -- **Inject `now_ms`** (monotonic ms) from the host; never read a clock inside the core. -- **One app, one IAP** — no separate "pro" binary. -- Call `adGate.maybeShowInterstitial()` only at natural breaks (ROM load, menu, save-state), - never mid-frame. Call `adGate.preload()` once after SDK init. - -## Free-tier model (core product rule) -- Free tier is **time-gated to 8 min per game session**, with **no save states** and **no - battery-backed (SRAM) saves**. Premium removes the timer and unlocks both. -- Each **completed** rewarded ad grants **+11 min** of play. Grant **only** on the reward - callback (`OnUserRewarded` / `didRewardUser`), never on load/show/dismiss. **Capped at 11 - grants/session** (max +22 min → 30 min total); the cap resets each game session. -- This logic is **implemented and tested** in the Rust core (`start_play`, `add_active_time`, - `grant_rewarded_time` → bool w/ cap, `can_offer_rewarded`, `reward_grants_remaining`, - `is_play_allowed`, `play_time_remaining_ms`; `base_play_ms`/`reward_play_ms`/ - `max_reward_grants_per_session` in `AdConfig`). 13 unit tests pass; bindings regenerated to - `core/generated/`. Full host flow in `docs/pre-implementation-addendum.md` §2c/§2f. -- Drive `add_active_time(...)` from **unpaused** emulation only (stop during ads, the run-out - prompt, and background) so paused time never burns the budget. - -## Release blockers for native (Rust) apps — verify, don't skip -- **Android 16 KB page size:** the Rust `.so` must be 16 KB-aligned (NDK r28+, AGP 8.5.1+, - linker `-Wl,-z,max-page-size=16384`). Verify `llvm-objdump -p …so | grep LOAD` shows `2**14`. - Non-compliant bundles are blocked by Play. -- **iOS ATT:** add `NSUserTrackingUsageDescription`; request consent before MAX init; **state - in App Store Connect review notes that you use ATT** or risk rejection. -- **iOS:** SKAdNetwork IDs (AppLovin Info.plist generator) + app `PrivacyInfo.xcprivacy` + - nutrition labels; third-party SDK privacy manifests **and signatures** are required, so ship - current AppLovin/adapter/RevenueCat versions (review runs binary checks; missing/unsigned → reject). -- **EEA/UK/Switzerland consent:** a Google-certified CMP integrated with the IAB TCF is required - to serve personalized ads (EEA/UK since 16 Jan 2024, CH since 31 Jul 2024). Use MAX's Terms & - Privacy (Google UMP) flow; consent must complete **before** MAX init. Separate from ATT. -- **Both stores:** rate Teen/12+, not child-directed (AppLovin forbids child-directed apps). - Ship the emulator only — no bundled copyrighted ROMs. - -## Mediation networks (recommended) -Run AppLovin MAX as the auction layer and enable these demand sources in the MAX dashboard: -- **Google bidding (AdMob / Ad Manager)** — usually the top demand; requires the AdMob app id - in the Android manifest (`com.google.android.gms.ads.APPLICATION_ID`) and iOS - `GADApplicationIdentifier`. -- **Meta Audience Network** -- **Unity Ads** -- **Pangle (ByteDance / TikTok)** - -For each: add its Gradle/SPM mediation adapter, enable it in MAX, and (iOS) add its -SKAdNetwork IDs via AppLovin's Info.plist generator. Expand later with Liftoff Monetize -(Vungle), Mintegral, InMobi, or ironSource as you tune the waterfall. - -## Decisions still open (ask before building purchase UI) -- One-time "Full Version / Remove Ads" (~$3.99, recommended) vs subscription. -- Exact tuning of the free-tier model: base 8 min / reward 11 min values and the 2-grant cap - (max +22 min). (Model itself is decided above.) -- `PremiumFeature` set beyond `SaveStates` + `BatterySaves` (e.g. fast-forward, shaders, cheats). -- Initial mediation networks (defaults below). - -## Conventions -- Comment style matches each language; Rust files carry full preambles. -- Keep desktop RustyNES builds unaffected — gate mobile-only deps behind a feature if needed. -- Verify SDK versions and store-policy text at implementation time; deadlines and APIs move. diff --git a/docs/monetization/README.md b/docs/monetization/README.md deleted file mode 100644 index 7c2f40e2..00000000 --- a/docs/monetization/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# RustyNES Monetization — Documentation Index - -> **Harvested into the RustyNES repo, 2026-06-23 — read this banner first.** These docs -> were folded in from the standalone `rustynes-monetization/` scaffold. Two things are -> now true in-repo: -> -> 1. **Locations.** The Rust policy core lives at **`crates/rustynes-monetization/`** (a -> real workspace crate — `AdPolicy`, 13 tests, UniFFI 0.31 to match `rustynes-mobile`). -> The Android/iOS app shells are staged, customized to the `com.doublegate.rustynes` -> namespace, at **`crates/rustynes-monetization/shells/{android,ios}/`** (reference glue -> to wire into the live app at launch — see that folder's `README.md`). Where these docs -> say `rustynes-monetization/core/…`, `android/…`, `ios/…`, read those in-repo locations. -> 2. **Launch timing is superseded by the v2.1.0 replan.** These docs were written when the -> Play debut was **v1.8.8** and the `PLAY_BUILD` flavor was wired at v1.8.2. Per the -> maintainer's 2026-06-23 mobile-launch replan, **both app-store launches are deferred to -> v2.1.0** (Android finalized v2.0.1–v2.0.4, iOS v2.0.5–v2.0.8, both verified v2.0.9, joint -> launch v2.1.0). So everywhere these docs say "v1.8.8 Play debut" / "flip the freemium -> layer on at v1.8.8", read **"v2.1.0 joint launch."** The freemium layer still ships -> **default-off behind `PLAY_BUILD`** and rides along dormant in the v1.8.x sideload builds -> until that launch. See [`../../to-dos/plans/v2.0.x-mobile-finalization-plan.md`](../../to-dos/plans/v2.0.x-mobile-finalization-plan.md). -> -> The **monetization model** (ad-supported freemium: AppLovin MAX + RevenueCat, a one-time -> **"Full Version / Remove Ads" ($3.99)** unlock) is the chosen path — a deliberate override -> of the v1.8.0 plan's ad-free $2.99 default, recorded in `to-dos/plans/v1.8.0-android-plan.md`. - -This `docs/` set is the single source of truth for shipping RustyNES as a freemium app -(AppLovin MAX ads on the free tier, removed by a RevenueCat **`premium`** entitlement) on -**Google Play** and the **Apple App Store**. Humans and Claude Code both read from here. - -## Tree - -``` -rustynes-monetization/ -├── CLAUDE.md # repo-root, read-first for Claude Code → points here -├── README.md # one-line signpost to this index -├── docs/ -│ ├── README.md # ← you are here (index + reading order) -│ ├── platform-setup-runbook.md # HUMAN: create accounts, dashboards, keys, products -│ ├── implementation-brief.md # CLAUDE CODE: how to implement + compliance blockers -│ ├── pre-implementation-addendum.md # CLAUDE CODE: ad strategy, perf, enhancements (read before building) -│ ├── recommendations.md # CLAUDE CODE: considerations beyond baseline (design holes, robustness, polish) -│ ├── build-and-bindings.md # build commands (cargo-ndk/cargo-swift) + FFI API table -│ └── rustynes-integration.md # CLAUDE CODE: integration onto the real RustyNES repo (read first) -├── core/ # shared Rust crate (AdPolicy + UniFFI scaffolding) -├── android/ # Kotlin shell (RevenueCat + AppLovin MAX) -└── ios/ # Swift shell (RevenueCat + AppLovin MAX) -``` - -## What each document is - -| Doc | Audience | Purpose | -|---|---|---| -| `platform-setup-runbook.md` | **You (human)** | Step-by-step account/dashboard setup: store developer accounts, the Individual-vs-Organization decision aid (§1a), RevenueCat, AppLovin MAX, mediation networks, tax/banking, costs, and timeline. | -| `implementation-brief.md` | **Claude Code / engineer** | The *why* and the *compliance*: decisions made, provider links, store-submission blockers (16 KB `.so`, ATT, privacy manifest), testing strategy, enhancements, open decisions. | -| `pre-implementation-addendum.md` | **Claude Code / engineer** | The *resolve-before-building* layer: ad-placement strategy for a vintage-console emulator, ad-content scoping, emulator-specific performance items (thread isolation, pause/resume, preload), product enhancements, a recommended baseline config, plus remote-config tuning (§9) and the analytics funnel (§10). | -| `recommendations.md` | **Claude Code / engineer** | Considerations & suggestions *beyond* the baseline: free-tier design holes to close (session-reset, offline trap, no-save cliff), engineering robustness (graceful SDK failure, tick cadence, test-mode ads, cross-platform entitlement), and store/product polish. Points to where compliance and tuning items live in the other docs. | -| `build-and-bindings.md` | **Claude Code / engineer** | The *how*: cargo-ndk / cargo-swift / uniffi-bindgen commands, key/entitlement config, and the verbatim generated FFI API table. (Formerly the project README.) | -| `rustynes-integration.md` | **Claude Code / engineer** | **Read first.** Maps this layer onto the real RustyNES repo: the **Compose + wgpu-`SurfaceView`** hybrid app, the shared `rustynes-mobile` bridge + `rustynes-android` glue, the determinism boundary, the `cargo-ndk`/AAB toolchain, and the **v1.8.8** Play debut. The maintainer has chosen the **ad-supported** RevenueCat + AppLovin model (this repo) as the **primary** path — a deliberate override of the v1.8.0 plan's ad-free default — with a one-time **"Full Version / Remove Ads" ($3.99)** unlock. | -| `../CLAUDE.md` | **Claude Code** | Read-first index + invariants + build order. Routes to the docs above. | - -## Reading order - -**If you're setting up the business side (human):** -1. `platform-setup-runbook.md` — start with §1 decisions and the §1a decision aid, then - work the critical-path sequence (§2). Begin the slow verifications (D-U-N-S, LLC, Apple - org review) on day one. - -**If you're implementing (Claude Code / engineer):** -1. `rustynes-integration.md` — **read first.** Maps this layer onto the real RustyNES repo (the - Compose + wgpu-`SurfaceView` hybrid app, the `rustynes-mobile` bridge, the determinism - boundary, the toolchain, the v1.8.8 Play debut). The **ad-supported** RevenueCat + AppLovin - model (this repo) is the chosen **primary** path — a deliberate override of the v1.8.0 plan's - ad-free default — at a one-time **"Full Version / Remove Ads" ($3.99)** unlock. -2. `../CLAUDE.md` — invariants and build order. -3. `implementation-brief.md` — decisions, compliance blockers, enhancements. -4. `pre-implementation-addendum.md` — ad strategy, content scoping, performance, the - recommended baseline ad config to implement first, remote-config tuning (§9), and the - analytics funnel (§10). -5. `recommendations.md` — considerations beyond the baseline (free-tier design holes, - robustness, polish); skim before locking the free-tier UX. -6. `build-and-bindings.md` — exact commands and the FFI surface. -7. Skim `platform-setup-runbook.md` §5–§9 for the entitlement id, product ids, the - key/credential names the code consumes, the consent-flow setup, and how to unlock the app - for the closed-test cohort (§5a). - -## The contract that ties it together - -- One RevenueCat entitlement, identifier **`premium`**, is the only premium signal. It flows - into the Rust core via `AdPolicy.set_premium(bool)`; ads and paid features derive from it. -- All ad cadence and paywall logic live in `crates/rustynes-monetization/src/monetization.rs` and are shared to both - platforms via UniFFI — never duplicated in Kotlin/Swift. -- **Free tier is time-gated to 8 min/session** (no save states, no battery saves); a completed - rewarded ad grants **+11 min**; the Full Version IAP removes the timer and unlocks saves. The - play-time logic lives in the core (addendum §2c/§2f). -- Bundle/package id, the `premium` entitlement, product ids, and the SDK keys must match - across the stores, RevenueCat, AppLovin, and the build config. The runbook's §8 table maps - every key to where it goes. - -## Status - -- ✅ Core compiles; **all 13 Rust unit tests pass** (interstitial pacing + the free-tier - play-time budget and 2-grant rewarded cap); Kotlin + Swift bindings are generated on demand - from the crate (see `build-and-bindings.md` — the committed `core/generated/` snapshot was - dropped as a regenerable build artifact). -- ✅ Free-tier play-time gate (8-min budget, +11 min per completed rewarded ad, 2-grant / - +22-min cap → 30 min max) implemented in `crates/rustynes-monetization/src/monetization.rs`; `PremiumFeature` gates - **six** features — `SaveStates` + `SaveOnExitResume` + `BatterySaves` + `FastForward` + - `Shaders` + `Cheats` (the free tier keeps full accuracy, video, audio, input, pause, and - in-session **rewind**; fast-forward / shaders / cheats are now premium). -- ⬜ Shell wiring still to do: the countdown UI and the run-out prompt (the rewarded-ad gate - `RewardedGate.{kt,swift}` is implemented) — see addendum §8. -- ⬜ Open decisions (see `implementation-brief.md` §10): one-time vs subscription, free-tier - tuning (base 8 min / reward 11 min; cap = 2 grants/session, +22 min), and the - Individual-vs-Organization account choice (runbook §1a). The free-tier *model* (8-min - timer, +11 min per rewarded ad, no save/battery saves) and the **`PremiumFeature` set - (the six above)** are decided — see addendum §2c/§2f. - -*Verify SDK versions, fees, and store-policy text at implementation/enrollment time — they move.* diff --git a/docs/monetization/build-and-bindings.md b/docs/monetization/build-and-bindings.md deleted file mode 100644 index a8d74696..00000000 --- a/docs/monetization/build-and-bindings.md +++ /dev/null @@ -1,210 +0,0 @@ -# RustyNES — Cross-Platform Monetization Skeleton - -A shared **Rust core** that owns all monetization *logic*, with thin **Android (Kotlin)** -and **iOS (Swift)** shells that own only the platform SDK *plumbing* — **RevenueCat** -for entitlements and **AppLovin MAX** for ads. Write the policy once; ship it to both -stores. - -``` -core/ (Rust) ──UniFFI──▶ Kotlin bindings ──▶ android/ (RevenueCat + AppLovin MAX) - AdPolicy Swift bindings ──▶ ios/ (RevenueCat + AppLovin MAX) -``` - -The core is the **single source of truth**. Both shells convert RevenueCat's -`CustomerInfo` into one boolean (`setPremium`) and ask the core when to show an ad -(`shouldShowInterstitial`). Ad cadence, the launch-grace window, and the paywalled -feature set live only in `crates/rustynes-monetization/src/monetization.rs`, so the two platforms cannot drift. -When a purchase completes, `setPremium(true)` makes every gate return `false` -instantly — ads stop with no app restart. - ---- - -## Layout - -``` -core/ shared Rust crate (rustynes-monetization) - Cargo.toml cdylib + staticlib + lib; uniffi-bindgen bin - uniffi.toml fixes Kotlin package (com.doublegate.rustynes.monetization.ffi) + Swift module (RustyNesMonetization) - uniffi-bindgen.rs in-crate binding generator (stable-Rust workaround) - src/lib.rs crate root; uniffi::setup_scaffolding!() - src/monetization.rs AdPolicy / AdConfig / PremiumFeature + unit tests -android/ - build.gradle.kts SDK deps, BuildConfig fields, jniLibs source set (excerpt) - AndroidManifest.xml permissions + AdMob app id meta-data (excerpt) - src/main/java/app/rustynes/ - RustyNesApp.kt Application: init MAX + RevenueCat, build core - Billing.kt RevenueCat wrapper → core.setPremium - AdGate.kt MAX interstitial gate ← core.shouldShowInterstitial -ios/ - Package.swift SwiftPM: RustyNesMonetization + RevenueCat + AppLovinSDK - Sources/RustyNesApp/ - RustyNesApp.swift @main App + Monetization coordinator - Billing.swift RevenueCat wrapper (PurchasesDelegate) - AdGate.swift MAX interstitial gate (MAAdDelegate) -``` - ---- - -## Build the Rust core & generate bindings - -Prerequisites: a stable Rust toolchain. - -```bash -cd core -cargo test --release # runs the pacing/entitlement unit tests -``` - -### Android (.so + Kotlin bindings) - -```bash -rustup target add aarch64-linux-android armv7-linux-androideabi \ - x86_64-linux-android i686-linux-android -cargo install cargo-ndk - -cd core -# 1) Build the per-ABI native libraries straight into the app's jniLibs: -cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ - -o ../android/src/main/jniLibs build --release - -# 2) Generate the Kotlin bindings (library mode reads types from the .so): -cargo run --features=cli --bin uniffi-bindgen -- generate \ - --library target/aarch64-linux-android/release/librustynes_monetization.so \ - --language kotlin --out-dir ../android/src/main/java -# → writes ../android/src/main/java/app/rustynes/ffi/rustynes_monetization.kt -``` - -The generated Kotlin loads the `.so` via JNA (declared in `build.gradle.kts`). - -### iOS (xcframework + Swift bindings) - -```bash -rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios -cargo install cargo-swift - -cd core -# Produces ../ios/RustyNesMonetization, a Swift package containing RustyNesMonetization.swift plus -# the librustynes_monetization xcframework as a binaryTarget. Name MUST match uniffi.toml. -cargo swift package --platforms ios --name RustyNesMonetization --release -``` - -(Manual alternative: `cargo build --release` per iOS triple, then -`cargo run --features=cli --bin uniffi-bindgen -- generate --library <.a> --language swift`, -then `xcodebuild -create-xcframework`.) - ---- - -## Configure the SDKs - -| What | Where to get it | Where it goes | -|------|-----------------|---------------| -| RevenueCat API key (Google) | RevenueCat dashboard → Project → API keys | `gradle.properties` → `revenueCatGoogleKey` | -| RevenueCat API key (Apple) | same, Apple key | iOS Info.plist → `REVENUECAT_API_KEY` | -| AppLovin SDK key | AppLovin dashboard → Account → General → Keys | Android `applovinSdkKey`; iOS `APPLOVIN_SDK_KEY` | -| MAX interstitial ad-unit id | AppLovin dashboard → MAX → Ad Units | `maxInterstitialAdUnitId` / `MAX_INTERSTITIAL_AD_UNIT_ID` | -| Entitlement id `"premium"` | RevenueCat → Entitlements | already referenced in code | - -In RevenueCat, create one **entitlement** named `premium`, attach it to your -non-consumable "remove ads" / full-version product (or subscription), and add that -product to the **current offering**. The shells purchase -`offerings.current.availablePackages.first`. - -For the AppLovin Google/AdMob adapter, keep the `com.google.android.gms.ads.APPLICATION_ID` -meta-data in `AndroidManifest.xml` and the equivalent `GADApplicationIdentifier` in the -iOS Info.plist. - ---- - -## Generated API the shells call (verbatim) - -| Rust | Kotlin | Swift | -|------|--------|-------| -| `AdPolicy::new(cfg, now_ms)` | `AdPolicy(config, nowMs: ULong)` | `AdPolicy(config:nowMs: UInt64)` | -| `default_ad_config()` | `defaultAdConfig()` | `defaultAdConfig()` | -| `set_premium(bool)` | `setPremium(premium)` | `setPremium(premium:)` | -| `is_premium()` | `isPremium()` | `isPremium()` | -| `should_show_interstitial(now_ms)` | `shouldShowInterstitial(nowMs)` | `shouldShowInterstitial(nowMs:)` | -| `notify_interstitial_shown(now_ms)` | `notifyInterstitialShown(nowMs)` | `notifyInterstitialShown(nowMs:)` | -| `feature_enabled(f)` | `featureEnabled(feature)` | `featureEnabled(feature:)` | -| `start_play()` | `startPlay()` | `startPlay()` | -| `add_active_time(delta_ms)` | `addActiveTime(deltaMs: ULong)` | `addActiveTime(deltaMs: UInt64)` | -| `can_offer_rewarded() -> bool` | `canOfferRewarded(): Boolean` | `canOfferRewarded() -> Bool` | -| `reward_grants_remaining() -> u32` | `rewardGrantsRemaining(): UInt` | `rewardGrantsRemaining() -> UInt32` | -| `grant_rewarded_time() -> bool` | `grantRewardedTime(): Boolean` | `grantRewardedTime() -> Bool` | -| `is_play_allowed() -> bool` | `isPlayAllowed(): Boolean` | `isPlayAllowed() -> Bool` | -| `play_time_remaining_ms() -> Option` | `playTimeRemainingMs(): ULong?` | `playTimeRemainingMs() -> UInt64?` | -| `AdConfig { base_play_ms, reward_play_ms, max_reward_grants_per_session, … }` | `AdConfig(…, basePlayMs: ULong, rewardPlayMs: ULong, maxRewardGrantsPerSession: UInt)` | `AdConfig(…, basePlayMs: UInt64, rewardPlayMs: UInt64, maxRewardGrantsPerSession: UInt32)` | -| `PremiumFeature::{SaveStates, SaveOnExitResume, BatterySaves, FastForward, Shaders, Cheats}` | `PremiumFeature.{SAVE_STATES, SAVE_ON_EXIT_RESUME, BATTERY_SAVES, FAST_FORWARD, SHADERS, CHEATS}` | `.saveStates / … / .fastForward / .shaders / .cheats` | - -### Build-out additions (2026-06-23) - -The core expansion added these to the surface above: - -| Rust | Kotlin | Swift | -|------|--------|-------| -| `begin_session(session_index, now_ms)` | `beginSession(sessionIndex: UInt, nowMs: ULong)` | `beginSession(sessionIndex: UInt32, nowMs: UInt64)` | -| `can_grant_offline_grace() -> bool` | `canGrantOfflineGrace(): Boolean` | `canGrantOfflineGrace() -> Bool` | -| `grant_offline_grace() -> bool` | `grantOfflineGrace(): Boolean` | `grantOfflineGrace() -> Bool` | -| `export_progress() -> PlayProgress` | `exportProgress(): PlayProgress` | `exportProgress() -> PlayProgress` | -| `restore_progress(p)` | `restoreProgress(progress: PlayProgress)` | `restoreProgress(progress: PlayProgress)` | -| `clamp_ad_config(cfg) -> AdConfig` | `clampAdConfig(cfg: AdConfig)` | `clampAdConfig(cfg: AdConfig)` | -| `AdConfig { …, first_session_play_ms, suppress_first_session, offline_grace_ms }` | `…, firstSessionPlayMs: ULong, suppressFirstSession: Boolean, offlineGraceMs: ULong` | `…, firstSessionPlayMs: UInt64, suppressFirstSession: Bool, offlineGraceMs: UInt64` | -| `PlayProgress { budget_ms, consumed_ms, reward_grants_this_session, offline_grace_used }` | `PlayProgress(budgetMs, consumedMs, rewardGrantsThisSession, offlineGraceUsed)` | `PlayProgress(budgetMs:consumedMs:rewardGrantsThisSession:offlineGraceUsed:)` | - -- **`PremiumFeature` now has six variants.** Per the 2026-06-23 "expand the premium set" - decision, **FastForward / Shaders / Cheats are now premium** — this **overrides** the - earlier doc stance (here and in `rustynes-integration.md` §4 / `pre-implementation-addendum.md`) - that fast-forward stayed free. The free tier keeps full accuracy, video, audio, input, - pause, and in-session rewind. -- **Free-tier budget:** **8-min** regular session, **30-min** generous first session - (`first_session_play_ms`, applied when the session index is 1; interstitials suppressed in - session #1 via `suppress_first_session`), +11 min per rewarded ad (cap 2 → 30 min on a - regular session). Host calls `begin_session(persisted_index, now)` at launch. -- **Offline grace:** at run-out with no rewarded fill, `grant_offline_grace()` gives a - one-time +2 min so an offline user degrades gracefully (recs §1b). -- **Kill-relaunch:** persist `export_progress()` and `restore_progress()` to keep the - timer/cap across a process death (recs §1a/§1f). -- **Remote config:** fetch values, overlay on `default_ad_config()`, pass through - `clamp_ad_config()` before building `AdPolicy` so a bad push can't brick the gate. - -`now_ms` is monotonic milliseconds: `SystemClock.elapsedRealtime()` on Android, -`DispatchTime.now().uptimeNanoseconds / 1_000_000` on iOS (the shells use -`mach_continuous_time()` so iOS counts deep-sleep like Android). - -The free-tier play-time gate (`start_play` … `play_time_remaining_ms`) implements the -budget, +11-min-per-rewarded-ad extension, and 2-grant per-session cap. Grant time -**only** from the rewarded reward callback (`OnUserRewarded` / `didRewardUser`) — see -`RewardedGate.{kt,swift}` in `shells/`. See `pre-implementation-addendum.md` §2c/§2f for -the host flow. - -The UniFFI Kotlin/Swift bindings are **generated on demand** from the crate with the -library-mode commands above (the Kotlin package is `com.doublegate.rustynes.monetization.ffi`, -the Swift module `RustyNesMonetization`). Regenerate whenever the core changes; the committed -`core/generated/` snapshot from the standalone scaffold was dropped as a build artifact. - ---- - -## Release timing note - -A brand-new Play Store submission must use **Google Play Billing Library 8+** by -**Aug 31, 2026** (v9 is current). RevenueCat bundles a compliant billing library and -updates it for you, so going through RevenueCat keeps you clear of that deadline -without tracking it yourself. - ---- - -## Where to call the gate - -Call `adGate.maybeShowInterstitial()` only at natural breaks — ROM loaded, returned to -the menu, save-state taken — never mid-frame. Call `adGate.preload()` once after SDK -init. Gate premium features with `core.featureEnabled(...)`. Everything else (cadence, -grace, premium suppression) is already handled inside the core. - -Premium is set only via `setPremium`, fed from RevenueCat. A RevenueCat promotional grant or a -Play license-tester test purchase both surface as `entitlements["premium"].isActive` and flow -through that same call — so unlocking the app for closed-test testers needs no app changes -(runbook §5a / brief §9a). `Billing` also carries a debug-only `TESTER_UNLOCK` override that -OR-s into `setPremium`, inert in release. - -Tune cadence in `AdConfig` (`min_interval_ms`, `launch_grace_ms`); the defaults are a -4-minute interval and a 30-second launch grace, deliberately conservative for an -emulator's long play sessions. diff --git a/docs/monetization/implementation-brief.md b/docs/monetization/implementation-brief.md deleted file mode 100644 index ba1d3342..00000000 --- a/docs/monetization/implementation-brief.md +++ /dev/null @@ -1,408 +0,0 @@ -# RustyNES — Mobile Monetization Implementation Brief - -> **Monetization model (decided).** This project's **ad-supported freemium** — **AppLovin MAX** -> ads + a **RevenueCat** `premium` entitlement, with a one-time **"Full Version / Remove Ads" -> ($3.99)** unlock — is the **primary** path. This is a deliberate maintainer override of the -> ad-free default in `to-dos/plans/v1.8.0-android-plan.md`. See `docs/rustynes-integration.md` for -> how it maps onto the real RustyNES repo (the Compose + wgpu-`SurfaceView` hybrid app, the -> `rustynes-mobile` bridge, the determinism boundary, and the v1.8.8 Play debut). - -**Audience:** Claude Code (and any engineer) implementing freemium monetization in the -RustyNES mobile ports. -**Companion artifact:** the in-tree compiling skeleton (`core/`, `android/`, `ios/`) — shared Rust -`AdPolicy` core + Android/iOS shells). This document explains the *why*, the *decisions*, -the *sources*, the *store-submission blockers*, and the *follow-up enhancements* that the -skeleton intentionally leaves open. Read `docs/build-and-bindings.md` for build commands; read -this for everything around it. - -> Scope note: "monetization" here = ads on the free tier + a paid tier that removes them. -> A short "beyond monetization" section at the end points at the rest of the mobile port. - ---- - -## 1. Decisions already made (this session) - -| Decision | Choice | Rationale | -|---|---|---| -| Entitlement / billing layer | **RevenueCat** (wraps Play Billing + StoreKit) | One entitlement model for both stores; RevenueCat keeps the underlying Play Billing Library current, sidestepping Google's version deadlines. | -| Ad mediation layer | **AppLovin MAX** | Strong mediation/auction for games; rewarded + interstitial + banner; one SDK fronts many demand networks. | -| Cross-language core | **Rust + UniFFI** | RustyNES is already Rust. UniFFI generates Kotlin **and** Swift bindings from one interface, so monetization *logic* is written and tested once. | -| Where logic lives | **In the Rust core** (`AdPolicy`) | Ad cadence, launch grace, premium suppression, and the paywalled-feature set cannot drift between Android and iOS because both call the same object. | -| Ad formats | **Rewarded (primary)** + sparing **interstitial** | Free tier is time-gated to 8 min/session; completed rewarded ads grant +11 min (addendum §2c/§2f). Interstitials only at game→library exit. | -| Paid model | One-time non-consumable **"Full Version / Remove Ads"** (~$3.99) | Removes the 8-min timer and unlocks save states + battery saves. Emulator users convert better on a one-time unlock than a subscription. | - -The contract the shells call (verbatim generated names) is in `docs/build-and-bindings.md`'s API table. -The single source of truth for premium status is `AdPolicy.set_premium(bool)`, fed from -RevenueCat's `CustomerInfo.entitlements["premium"].isActive`. - ---- - -## 2. Provider & reference links - -**RevenueCat** -- Quickstart: https://www.revenuecat.com/docs/getting-started/quickstart -- Configuring the SDK: https://www.revenuecat.com/docs/getting-started/configuring-sdk -- Subscription / entitlement status: https://www.revenuecat.com/docs/customers/customer-info -- Android SDK reference: https://sdk.revenuecat.com/android/ (purchases-android) -- iOS SDK (SPM): https://github.com/RevenueCat/purchases-ios -- Paywalls (RevenueCatUI): https://www.revenuecat.com/docs/tools/paywalls -- Play Billing v9 migration (RevenueCat blog): https://www.revenuecat.com/blog/engineering/play-billing-v9/ - -**AppLovin MAX** -- Android integration (new init API): https://support.applovin.com/en/max/android/overview/integration -- iOS integration: https://support.applovin.com/en/max/ios/overview/integration -- iOS SKAdNetwork (Info.plist generator): https://support.applovin.com/en/max/ios/overview/skadnetwork -- iOS privacy: https://support.applovin.com/en/max/ios/overview/privacy -- Terms & privacy / consent (UMP) flow: https://support.applovin.com/en/max/ios/overview/terms-and-privacy-policy-flow -- Android SDK releases: https://github.com/AppLovin/AppLovin-MAX-SDK-Android/releases -- iOS Swift package: https://github.com/AppLovin/AppLovin-MAX-Swift-Package - -**UniFFI** -- Repo: https://github.com/mozilla/uniffi-rs -- User guide: https://mozilla.github.io/uniffi-rs/ -- Bindings generation: https://mozilla.github.io/uniffi-rs/latest/bindings.html -- cargo-swift: https://github.com/antoniusnaumann/cargo-swift -- cargo-ndk: https://github.com/bbqsrc/cargo-ndk - -**Platform billing (reference even though RevenueCat fronts them)** -- Play Billing release notes: https://developer.android.com/google/play/billing/release-notes -- Play Billing deprecation/deadlines: https://developer.android.com/google/play/billing/play-developer-apis-deprecations -- StoreKit: https://developer.apple.com/documentation/storekit - -**Compliance (see §6–§7)** -- Android 16 KB page sizes: https://developer.android.com/guide/practices/page-sizes -- Apple privacy & ATT: https://developer.apple.com/app-store/user-privacy-and-data-use/ -- Apple privacy manifests: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - ---- - -## 3. Integrate the core into the RustyNES workspace - -The skeleton ships `rustynes-monetization` as a standalone crate. In the real repo, fold it into -the existing Cargo workspace rather than duplicating: - -1. Add a `[workspace]` member (e.g. `crates/monetization` or a module inside the existing - mobile-facing crate). Keep the emulator core and the monetization module in the **same** - FFI crate so a single `.so` / `.a` and a single set of UniFFI bindings cover both. One - `uniffi::setup_scaffolding!()` per crate — if the emulator already exports a UniFFI - surface, merge the monetization `#[uniffi::export]` items into that crate instead of - adding a second scaffolding call. -2. The mobile FFI crate's `crate-type` must include `cdylib` (Android) and `staticlib` - (iOS) alongside `lib`. Pure desktop builds of RustyNES are unaffected. -3. Gate mobile-only deps behind a feature (e.g. `mobile`) so desktop builds don't pull - UniFFI unnecessarily, if that matters to the existing build. -4. Keep `now_ms` host-injected (as the skeleton does). Do **not** read the clock inside the - core — it preserves determinism and matches RustyNES's existing testability discipline. - -**Build outputs:** -- Android: `cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 -o android/src/main/jniLibs build --release` → then `uniffi-bindgen ... --language kotlin`. -- iOS: `cargo swift package --platforms ios --name RustyNesMonetization --release` (or manual xcframework + `--language swift`). - ---- - -## 4. Implementation task list (ordered) - -1. **Core**: merge `monetization.rs` into the FFI crate; `cargo test` green. Decide the - real `PremiumFeature` set (see §8/§10) — this enum is the only place the paywall is defined. -2. **Android native build**: wire `cargo-ndk` into Gradle (the Mozilla Rust Android Gradle - plugin or a manual task), output to `jniLibs`. **Verify 16 KB alignment (§6).** -3. **Android bindings**: generate Kotlin into `app/rustynes/ffi`; add the JNA dep. -4. **Android shells**: drop in `RustyNesApp.kt`, `Billing.kt`, `AdGate.kt`; fill in real keys - via `gradle.properties`; call `adGate.preload()` after SDK init and - `adGate.maybeShowInterstitial()` at break points; gate features with `core.featureEnabled`. -5. **RevenueCat dashboard**: create the `premium` entitlement, the product(s), and the - current offering; map products to the entitlement. -6. **AppLovin dashboard**: create the app + interstitial (and rewarded) ad units; add - mediation networks (§7). -7. **Android compliance**: AD_ID permission, Data safety form, content rating, consent - flow (§6, §7). -8. **Ship Android** to internal testing; verify entitlement flips and ad cadence on device. -9. **iOS native build**: `cargo swift` package; add to the Xcode/SwiftPM project. -10. **iOS shells**: `RustyNesApp.swift`, `Billing.swift`, `AdGate.swift`; inject keys via - Info.plist/xcconfig. -11. **iOS compliance**: ATT string, SKAdNetwork IDs, privacy manifest, nutrition labels, - App Review note (§6, §7). -12. **Ship iOS** ~1 week later via TestFlight → App Store. - ---- - -## 5. The "ads vanish on the paid tier" mechanism (recap for reviewers) - -There is no per-binary split and no separate "pro" app. One app, one IAP. Flow: - -1. Launch → RevenueCat fetches `CustomerInfo` → shell calls `core.set_premium(active)`. -2. Every ad opportunity calls `core.should_show_interstitial(now_ms)`, which returns - `false` whenever premium is set. The ad SDK is never even asked to show. -3. Purchase completes → RevenueCat listener/delegate fires → `core.set_premium(true)` → - ads stop instantly, no restart. -4. Restore Purchases re-activates the entitlement on a new device/reinstall. - ---- - -## 6. Store-submission blockers that specifically affect native (Rust) apps - -These are the items most likely to **block a release** and that generic ad/IAP guides omit. - -### 6a. Android — 16 KB memory page size (CRITICAL for the Rust `.so`) -New apps and updates targeting Android 15 (API 35)+ must support 16 KB page sizes on -64-bit devices; Google Play enforces this (rolled out Nov 1, 2025, one-time extension -window since closed). Any app shipping a native `.so` — which RustyNES does — must have -its shared libraries **16 KB-aligned** or Play blocks the upload. - -Fix for the Rust toolchain: -- Build with **NDK r28+** (defaults the linker to a 16 KB max page size). -- Belt-and-suspenders: force the linker flag in `.cargo/config.toml`: - ```toml - [target.aarch64-linux-android] - rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - [target.armv7-linux-androideabi] - rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - ``` -- Use **AGP 8.5.1+** so the AAB zip-aligns uncompressed `.so` files on 16 KB boundaries. -- Verify: `llvm-objdump -p librustynes_monetization.so | grep LOAD` → every `align` must be - `2**14` (16384), not `2**12`. Or use Android Studio APK Analyzer / the Play Console - App Bundle Explorer "Memory page size: Supports 16 KB". - -### 6b. iOS — App Tracking Transparency (ATT) -- Add `NSUserTrackingUsageDescription` to Info.plist (AppLovin suggests copy like - "This uses device info for more personalized ads and content."). -- Use AppLovin's built-in consent flow, which can present the ATT prompt; request consent - **before** initializing the MAX SDK (the SDK records consent state at init). -- In **App Store Connect → App Review notes**, explicitly state that you use the ATT - framework. Omitting this is a common rejection cause. -- Do not force/trick consent; respect the user's ATT answer (Guideline 5.1.1(iv)). - -### 6c. iOS — SKAdNetwork / AdAttributionKit IDs -Each mediated network needs its `SKAdNetworkIdentifier` entries in Info.plist. Use -AppLovin's **Info.plist Generator** (link in §2), check every network you enable, and paste -the concatenated `SKAdNetworkItems` list. More IDs = more eligible demand = higher eCPM. - -### 6d. iOS — Privacy manifest + nutrition labels -- Apple requires a privacy manifest. AppLovin, RevenueCat, and the mediation adapters ship - their own SDK manifests, but **the app target needs its own `PrivacyInfo.xcprivacy`** - declaring tracking (`NSPrivacyTracking = true`), tracking domains, collected data types - (e.g. device id, advertising data, purchases), and any required-reason API usage. -- **Third-party SDK privacy manifests *and* signatures are mandatory** for listed SDKs - (AppLovin and the ad adapters are on Apple's list) when you submit a new app, or an update - that adds them. Apple's review now runs binary/dynamic checks against the declarations, so - **ship current SDK/adapter versions** (older ones predate signed manifests) and confirm - each adapter bundles one — a missing or unsigned manifest is a rejection cause. -- Fill the **App Privacy ("nutrition label")** section in App Store Connect to match what - the ad SDK collects. - -### 6e. Both stores — children / audience -AppLovin's terms **prohibit using the SDK in child-directed apps**. An NES emulator can -attract minors but is not "directed to children" if rated and marketed accordingly: -- Google Play: set Target Audience to teen/adult, complete the content rating - questionnaire (IARC), and do **not** enroll in "Designed for Families." -- Apple: set an age rating of 12+ or higher; do not flag the app as kids-category. -- If you ever target children, ads + tracking become heavily restricted (COPPA / Families) - and AppLovin is not permissible — a different monetization design would be required. - -### 6f. Both stores — emulator policy & ROM legality -Emulators are allowed on both stores (Apple updated its guidelines in 2024 to permit retro -game console emulators; Google Play allows them). **Verify the current text** of Apple -Guideline 4.7 and Google's policy at build time. Constraints to honor: -- Ship the **emulator only** — do **not** bundle or distribute copyrighted ROMs. -- Provide a file-import path for user-supplied ROMs / homebrew (SAF on Android, - `UIDocumentPicker` on iOS). -- Keep any "where to get games" guidance pointed at legal homebrew/public-domain sources. - -### 6g. EEA / UK / Switzerland — certified CMP + IAB TCF consent (CRITICAL for ad revenue) -To serve **personalized** ads in these regions Google requires a **Google-certified Consent -Management Platform integrated with the IAB TCF** — enforced for the EEA/UK since **16 Jan -2024** and Switzerland since **31 Jul 2024**. Without it you are limited to -non-personalized / limited ads (a real revenue hit) and Google demand routed through MAX can -be disqualified. Use MAX's **Terms & Privacy Policy flow** (Google UMP) — wiring in §7 — and -**complete the consent flow before initializing the MAX SDK** (the SDK captures consent at -init). This is independent of iOS ATT (§6b): an EU iOS user needs *both* a TCF consent and -an ATT answer. The certified-CMP list and TCF version requirements move (e.g. the TCF v2.3 -migration) — verify against Google's current policy at submission. - ---- - -## 7. Mediation, consent & dashboard setup - -**Fill the MAX waterfall.** MAX is only as good as the demand connected to it. Add the -adapter dependencies and enable each network in the MAX dashboard, then (iOS) add its -SKAdNetwork IDs: -- Google bidding (AdMob/Ad Manager) — usually highest demand; requires the AdMob app id in - the Android manifest / iOS `GADApplicationIdentifier`. -- Meta Audience Network, Unity Ads, Liftoff Monetize (Vungle), Pangle, Mintegral, InMobi, - ironSource. -- In-app bidding is configured server-side in MAX; the SDK just needs the adapter present. - -**Consent (GDPR/UK/EEA).** Enable the MAX Terms & Privacy Policy flow (Google UMP): -- Android: configure in the init builder or `applovin_settings.json`. -- iOS: `AppLovin-Settings.plist` → `ConsentFlowInfo` (`ConsentFlowEnabled = YES`, - `ConsentFlowPrivacyPolicy = `), or set programmatically before init. -- Create & publish the GDPR message in the AdMob dashboard so the UMP form can display. -- If you bring your own CMP, establish consent **before** initializing MAX. - -**US state privacy.** Set the "Do Not Sell" / opt-out flags via the MAX privacy APIs as -applicable. - ---- - -## 8. Recommended enhancements (with concrete hooks) - -### 8a. Rewarded ads (the free-tier engine — highest priority) -Rewarded video is opt-in, the highest-eCPM format, and store-friendly. **In RustyNES it is -the core of the free tier:** the free tier is time-gated to **8 minutes** per game session -(no save states, no battery-backed saves), and each **completed** rewarded ad grants **+2 -minutes** of play. The full mechanic, core model, and host flow are in -`pre-implementation-addendum.md` §2c and §2f. Key rule: grant the time **only** on the ad -network's reward callback — Android `OnUserRewarded`, iOS `didRewardUser` — never on load, -show, or dismiss, so the grant maps to a qualifying view. - -Core additions (**now implemented** in `monetization.rs`; see addendum §2f): `start_play()`, -`add_active_time(ms)`, `grant_rewarded_time()` (returns `bool`, enforces the 2-grant cap), -`can_offer_rewarded()`, `reward_grants_remaining()`, `is_play_allowed()`, -`play_time_remaining_ms()`, plus `base_play_ms` (480_000), `reward_play_ms` (660_000), and -`max_reward_grants_per_session` (2) in `AdConfig`. All 17 unit tests pass and bindings are -regenerated. - -```rust -// add to monetization.rs, exported alongside AdPolicy -#[uniffi::export] -impl AdPolicy { - /// Offer a rewarded ad whenever the free user is out of (or low on) play time. - /// Premium users never need it. - pub fn can_offer_rewarded(&self) -> bool { - !self.is_premium() - } - // grant_rewarded_time() (addendum §2f) is what the reward callback calls to add +11 min. -} -``` -Shell side: `MaxRewardedAd` (Android) / `MARewardedAd` (iOS), same delegate pattern as -`AdGate`; on the reward callback call `grant_rewarded_time()` then resume the emulator. Reuse -the same rewarded unit for optional conveniences (a session unlock of fast-forward, an extra -save-state slot, a shader trial) as a funnel toward the Full Version purchase. - -### 8b. RevenueCat Paywalls (RevenueCatUI) -Instead of hand-building purchase UI, configure a paywall in the RevenueCat dashboard and -render it with `PaywallView` (SwiftUI) / `Paywall` Composable / `PaywallActivityLauncher` -(Android). It wires to the same offering the skeleton's `purchasePremium` already reads. - -### 8c. Remote-tune ad cadence -`AdConfig` is passed in at construction, so source `min_interval_ms` / `launch_grace_ms` — -**and the free-tier `base_play_ms` / `reward_play_ms` / `max_reward_grants_per_session`** — -from a remote config (RevenueCat metadata or Firebase Remote Config) and adjust pacing and -the timer/cap without an app update. The full field list and an experiment plan are in -addendum **§9**; the funnel events to log are in addendum **§10**. - -### 8d. Impression-level ad revenue (ILRD) -MAX emits per-impression revenue callbacks. Forward them to analytics and/or RevenueCat to -compute true LTV per cohort and to A/B the ad/no-ad and cadence variants. - -### 8e. Formats to use sparingly or avoid -- **Banner ads:** poor fit over a fullscreen emulator viewport. If used, restrict to menu - screens only — never over the game. -- **App-open ads:** easy to trip the disruptive-ads policy. If used, gate through the core - and never on a cold start that interrupts immediate gameplay. - -### 8f. A/B and experiments -RevenueCat Experiments (price/paywall) + MAX A/B (waterfall) once you have baseline data. - ---- - -## 9. Testing strategy - -- **AppLovin:** enable test mode for your device; start with test ad units; use the - **Mediation Debugger** (`AppLovinSdk.getInstance(ctx).showMediationDebugger()` / - `ALSdk.shared().showMediationDebugger()`) to confirm each network adapter initializes and - to inspect the "Privacy States" the SDK logged. -- **RevenueCat / IAP sandbox:** - - iOS: add a **StoreKit Configuration file** in Xcode for local purchase testing, or use - App Store sandbox testers; confirm `entitlements["premium"].isActive` flips and that - `set_premium(true)` reaches the core (ads stop without restart). - - Android: add **license testers** in Play Console, publish to an internal track, ensure - products are **active**; test purchase + restore + cancellation. -- **Core logic:** the Rust unit tests (13, all passing) cover premium suppression, launch - grace, interval enforcement, mid-session upgrade, feature gating, the free-tier play-time - budget + 2-grant cap, and `granted_entitlement_fully_unlocks_app` — which pins that a - RevenueCat grant / sandbox purchase unlocks every gate (the contract your closed-test cohort - relies on). Extend them as you add behavior; this is the cheapest place to catch bugs. -- **16 KB:** test on an Android 15 **16 KB** emulator system image; confirm no native crash - and the bundle reports "Supports 16 KB." - -### 9a. Granting your closed-test cohort the unlocked version -Google requires a personal account to run **≥12 testers for 14 continuous days** before -production (you're doing 15). Give them the Full Version without charging them — full -dashboard steps are in **runbook §5a**; the engineering view: -- **Nothing special in the app.** A RevenueCat **promotional grant** and a Play **license - tester** test purchase both surface as `entitlements["premium"].isActive == true`, which - `Billing` already forwards via `set_premium(true)`. The - `granted_entitlement_fully_unlocks_app` test pins this. Prefer grants to just unlock the - app; prefer license-tester purchases to validate the real billing path. -- **Debug override (local QA only, not the closed track).** `Billing.kt` / `Billing.swift` - carry a `TESTER_UNLOCK` override that OR-s premium into the single `set_premium` path. It is - double-gated — Android `BuildConfig.DEBUG && BuildConfig.TESTER_UNLOCK` (true only in the - debug build type; see `build.gradle.kts`), iOS `#if DEBUG` + Info.plist - `RUSTYNES_TESTER_UNLOCK`. It compiles to a constant `false` in release, and the closed-test - track is a release build, so it never unlocks for the 15 testers — use a grant or license - tester for them. The override adds **no second premium flag**: it still flows through - `set_premium`, preserving the single-source-of-truth invariant. - ---- - -## 10. Open decisions needed before building purchase UI - -1. **Paid model:** one-time non-consumable "Full Version / Remove Ads" (recommended) vs - subscription? Affects product setup and the paywall copy. -2. **Free-tier scope (largely decided):** the free tier keeps the full, cycle-accurate - emulator but is **time-gated to 8 minutes per game session**, with **no save states** and - **no battery-backed (SRAM) saves**; a **completed rewarded ad grants +11 minutes** (see - addendum §2c/§2f). The Full Version removes the timer and unlocks save states + battery - saves, so `PremiumFeature` includes `SaveStates`, `SaveOnExitResume`, `BatterySaves`, and - (decided 2026-06-23) `FastForward`, `Shaders`, `Cheats`. Still to confirm: the base 8 min - / reward 11 min values. - Extensions are **capped at 2 per session** (max +22 min → 30 min total). -3. **Rewarded in the free tier?** Recommended yes — best eCPM and least intrusive. -4. **Initial mediation networks** to enable in MAX. -5. **Child-directed?** Recommended **no** (rate Teen/12+); enabling kids mode would forbid - AppLovin and require a different plan. - ---- - -## 11. Beyond monetization — mobile-port pointers - -The monetization layer is one slice of porting RustyNES to mobile. For coherence, the same -shared-core/thin-shell split applies to the rest. Brief pointers (out of scope for this -brief but worth tracking): - -- **Rendering:** core emits a framebuffer; present via Android `SurfaceView`/`GameActivity` - + GLES/Vulkan, and iOS `MetalKit`/`CAMetalLayer`. Avoid copying the framebuffer across - FFI per frame if possible — share a buffer or render natively from a pointer. -- **Audio:** Oboe (Android) / AVAudioEngine (iOS) fed by the APU sample stream; mind - latency and the ad-display interruption (pause audio while an interstitial is up). -- **Input:** on-screen touch gamepad + hardware controllers (Android `InputDevice`, - iOS `GameController` / MFi). -- **ROM & save I/O:** SAF / `UIDocumentPicker` for user-supplied ROMs; app-sandbox storage - for save states and battery saves; optional cloud sync. -- **Lifecycle:** run emulation on a dedicated thread (never the UI thread); pause on - background and when an ad is on screen; persist/restore state across interruptions. - Monetization FFI calls are cheap and infrequent, so they can run on the UI thread. - ---- - -## 12. Quick reference — generated FFI surface - -| Rust | Kotlin (`com.doublegate.rustynes.monetization.ffi`) | Swift (`RustyNesMonetization`) | -|---|---|---| -| `AdPolicy::new(cfg, now_ms)` | `AdPolicy(config, nowMs: ULong)` | `AdPolicy(config:nowMs: UInt64)` | -| `default_ad_config()` | `defaultAdConfig()` | `defaultAdConfig()` | -| `set_premium(bool)` | `setPremium(premium)` | `setPremium(premium:)` | -| `is_premium()` | `isPremium()` | `isPremium()` | -| `should_show_interstitial(now_ms)` | `shouldShowInterstitial(nowMs)` | `shouldShowInterstitial(nowMs:)` | -| `notify_interstitial_shown(now_ms)` | `notifyInterstitialShown(nowMs)` | `notifyInterstitialShown(nowMs:)` | -| `feature_enabled(f)` | `featureEnabled(feature)` | `featureEnabled(feature:)` | -| `PremiumFeature::{SaveStates, SaveOnExitResume, BatterySaves}` | `PremiumFeature.SAVE_STATES` … | `.saveStates` … | - -`now_ms` = monotonic ms: `SystemClock.elapsedRealtime()` (Android) / -`DispatchTime.now().uptimeNanoseconds / 1_000_000` (iOS). - ---- - -*Part of the RustyNES monetization doc set (see `docs/README.md`). Verify SDK versions and -the current store policy text at implementation time — versions and deadlines move.* diff --git a/docs/monetization/platform-setup-runbook.md b/docs/monetization/platform-setup-runbook.md deleted file mode 100644 index 318f43b1..00000000 --- a/docs/monetization/platform-setup-runbook.md +++ /dev/null @@ -1,352 +0,0 @@ -# RustyNES — Platform & Account Setup Runbook - -> **Monetization model (decided).** The **ad-supported** path is primary: set up **both** -> **RevenueCat** (§5) and **AppLovin MAX** (§6), plus the Google Play one-time product (§4). The -> paid unlock is a one-time **"Full Version / Remove Ads" ($3.99)** keyed to the RevenueCat -> `premium` entitlement. This deliberately overrides the ad-free default in -> `to-dos/plans/v1.8.0-android-plan.md`; see `docs/rustynes-integration.md`. - -A step-by-step checklist **for you to follow** to stand up every account and dashboard -needed to ship RustyNES as a freemium app (AppLovin MAX ads + a RevenueCat "remove ads" -purchase) on **Google Play (Android first)** and the **Apple App Store (iOS ~1 week later)**. - -This covers the parts an engineer/Claude Code can't do for you because they need your -identity, payment details, and credentials. The code/build side lives in the skeleton zip, -the implementation brief, and `CLAUDE.md`. - ---- - -## 0. At a glance - -| Account | Cost | Verification | Key gotcha | -|---|---|---|---| -| Apple Developer Program | **$99 / year** | Org: D-U-N-S + verification call | Org enrollment can take days–weeks | -| Google Play Console | **$25 one-time** | ID; Org: D-U-N-S | **Personal accounts** must run a 12-tester / 14-day closed test before production | -| RevenueCat | **Free** to $2,500 MTR, then ~1% | — | "MTR" is gross revenue *before* store commission | -| AppLovin MAX | **Free** SDK | Payout/tax info to get paid | Each mediated network needs its own account | -| AdMob / Meta / Unity / Pangle | **Free** | per network | Needed to fill the MAX waterfall | - -**Store commissions:** Apple 30% (15% under the Small Business Program / <$1M / year, and -15% on subscriptions). Google 15% on the first $1M/year, 30% above; 15% on subscriptions. -**Enroll in each store's Small Business Program** to get the 15% rate. - ---- - -## 1. Decisions to make before you start - -- [ ] **Account type — Apple:** Individual (your legal name is the seller) vs Organization - (a legal entity name is the seller; requires a free **D-U-N-S number**, a public - business website, and an Apple verification phone call). -- [ ] **Account type — Google:** Personal vs Organization. **This one has teeth:** - - *Personal* accounts created after Nov 13, 2023 must run a **closed test with ≥12 - testers opted-in for 14 continuous days** before you can request production access. - - *Organization* accounts are **exempt** from that testing gate but need a free - **D-U-N-S number** and business verification. - - **Recommendation:** if you have or will form an LLC, register **Organization** on - Google to skip the 12-tester gate and ship faster. If staying personal, plan the - 14-day closed test into the timeline (it overlaps with QA you'd do anyway). - *Full trade-off in §1a below.* -- [ ] **Paid model:** one-time non-consumable "Full Version / Remove Ads" (recommended for - an emulator) vs auto-renewable subscription. Determines the product type you create. -- [ ] **Bundle / package identifier:** pick one and use it **identically** everywhere - (Apple, Google, RevenueCat, AppLovin). Suggested: `app.rustynes` (matches the - skeleton's `applicationId` / namespace). -- [ ] **App name & store presence:** final name, icon, screenshots, privacy policy URL, - support email/URL. A **privacy policy URL is required** (and the AppLovin consent - flow needs it too). - ---- - -## 1a. Decision aid — Individual / Personal vs. Organization (LLC) - -This single choice drives the seller name on both stores, whether Google's 12-tester gate -applies, and your liability/tax posture. You do **not** have to match across stores (Apple -Individual + Google Personal is fine), but matching gives consistent seller branding. - -| Factor | Individual / Personal | Organization (e.g. LLC) | -|---|---|---| -| Seller name shown on store | Your personal legal name | Company legal entity name | -| Setup speed | Fast — no D-U-N-S, no business docs | Slower — free **D-U-N-S** + verification (Apple does a phone call) | -| Google 12-tester / 14-day gate | **Applies** (accounts after Nov 13 2023) | **Exempt** | -| Liability | Personal; no separation of assets | LLC separates personal from business (consult a pro) | -| Cost beyond store fees | None | LLC formation + state annual fees + maybe registered agent | -| Best for | Quick validation, hobby/solo launch | Ongoing product, multiple apps, taking payments, possible team | - -**Choose Individual / Personal if:** you want to ship fast with minimal overhead, you're -validating the idea, and you're comfortable with your legal name as the public seller. -Just budget the Google **12-tester / 14-day** closed test (it overlaps with the QA testing -you'd run anyway, so it's rarely net-new work — only net-new *calendar time*). - -**Choose Organization (LLC) if:** RustyNES is a serious, ongoing product you intend to -monetize and possibly expand, you want a company name as the seller, and you want to -separate business finances/liability. The main concrete launch payoff is **skipping -Google's 12-tester gate**; the main cost is the slower D-U-N-S/verification path (start it -on day one — see §2). - -**Recommendation:** for a real, revenue-generating launch you plan to maintain, the -Organization route is usually worth it — chiefly for the Google testing-gate exemption, the -professional seller name, and the liability separation. If you mainly want to get a build in -front of users quickly, Individual/Personal is perfectly fine; just start the 14-day Google -closed test early. The slow steps either way are the **D-U-N-S number** and **LLC formation**, -so if there's any chance you'll go the org route, begin those now — they're easy to abandon -and hard to rush. - -> Not legal or tax advice. LLC formation, liability, and tax treatment vary by state and by -> your situation — confirm specifics with an attorney or accountant before deciding. - ---- - -## 2. Recommended sequence (critical path) - -Long poles first — start the slow verifications on day one. - -1. **Request a D-U-N-S number** (free) if going Organization on either store — - https://developer.apple.com/support/D-U-N-S/ — it can take days. -2. **Start Apple Developer Program enrollment** (org verification is the slowest step). -3. **Register Google Play Console**; if personal, **begin recruiting/onboarding the 12 - testers immediately** and start the 14-day clock as soon as you have an installable build. -4. **Create RevenueCat + AppLovin accounts** (instant) and the network accounts. -5. Configure products, entitlements, ad units, and credentials (Sections 5–7). -6. Build → internal/closed testing → store review → production. Ship Android, then iOS. - ---- - -## 3. Apple Developer Program + App Store Connect - -**Enroll** — https://developer.apple.com/programs/enroll/ ($99/yr) -- [ ] Apple Account with **two-factor authentication** on; use your **legal name** (aliases - delay approval). -- [ ] Choose Individual or Organization. Organization additionally needs a **D-U-N-S - number** tied to the legal entity, legal binding authority, and a public website; - Apple verifies by phone. -- [ ] Pay the $99 and accept the license agreement. - -**App Store Connect** — https://appstoreconnect.apple.com -- [ ] **Agreements, Tax, and Banking** → sign the *Paid Apps* agreement and add **bank + - tax** details. IAP cannot be sold until this is complete. -- [ ] Enroll in the **App Store Small Business Program** (15% rate). -- [ ] Register the **Bundle ID** (Certificates, Identifiers & Profiles) = `app.rustynes`. -- [ ] Create the **app record** (name, primary language, bundle ID). -- [ ] Create the **in-app purchase**: a **Non-Consumable** named e.g. `remove_ads` - (or an auto-renewable subscription). Set price, localized name/description. -- [ ] Create credentials RevenueCat needs (Section 5): an **In-App Purchase Key** (.p8, - under Users and Access → Integrations) and/or the **app-specific shared secret**. -- [ ] Info.plist items for the build: `NSUserTrackingUsageDescription`, - `GADApplicationIdentifier` (AdMob app id), SKAdNetwork IDs, and an app - `PrivacyInfo.xcprivacy` (see the implementation brief §6). Complete **App Privacy** - (nutrition labels) in App Store Connect. - ---- - -## 4. Google Play Console - -**Register** — https://play.google.com/console/signup ($25 one-time) -- [ ] Google account with **2-Step Verification**. -- [ ] Choose **Personal** or **Organization** (see §1). Organization needs a **D-U-N-S - number** + business documents. -- [ ] Complete **identity verification** (government ID; org: business docs). -- [ ] Set up the **Payments profile** (merchant) with **bank + tax** info — required for IAP. -- [ ] Enroll in Play's reduced **15%** service-fee tier (automatic for new accounts under $1M). - -**App setup** -- [ ] Create the app; package name = `app.rustynes` (must match everywhere). -- [ ] Create the **in-app product**: a **Managed product** `remove_ads` (non-consumable), - or a Subscription. Activate it. -- [ ] **Data safety** form, **content rating** (IARC) — rate Teen/12+, **not** child-directed - (AppLovin forbids child-directed apps; see brief §6). -- [ ] Declare the **AD_ID** permission usage. -- [ ] Create a **service account** for RevenueCat (Section 5): in Google Cloud, create a - service account + JSON key; in Play Console → Users & permissions (or API access), - grant it access to view financial data and manage orders/subscriptions. -- [ ] If **Personal** account: set up the **Closed testing** track and run it with **≥12 - testers for 14 continuous days**, then apply for **production access**. -- [ ] **License testing** (so testers aren't charged for the Full Version): Play Console → - **Settings → License testing** (account level) → add the **same** tester Gmail - addresses. Track membership alone is *not* enough — without this they pay real money. - Full detail and the no-purchase alternative are in **§5a**. - ---- - -## 5. RevenueCat (entitlement layer) - -**Sign up** — https://app.revenuecat.com (free up to $2,500 MTR, ~1% above) -- [ ] Create a **Project** for RustyNES. -- [ ] Add the **App Store app**: bundle id `app.rustynes` + the App Store Connect - shared secret / In-App Purchase Key from §3. -- [ ] Add the **Play Store app**: package `app.rustynes` + upload the **service account - JSON** from §4. -- [ ] Create **Products** mapped to the App Store product id and the Play product id - (`remove_ads`). -- [ ] Create an **Entitlement** with identifier **`premium`** (this exact string is what the - code checks) and attach the products to it. -- [ ] Create an **Offering** (mark it *current*) containing a **Package** with the product(s) - — the shells purchase `offerings.current.availablePackages.first`. -- [ ] Copy the **public SDK API keys**: the Apple key (`appl_…`) and the Google key - (`goog_…`) for the build (Section 8). -- [ ] *Optional:* configure a **Paywall** (RevenueCatUI) to skip building purchase UI. - ---- - -## 5a. Unlock the paid version for your closed-test cohort (15 testers / 14 days) - -Google requires a personal account to run a closed test with **≥12 testers opted-in for 14 -continuous days** before production; you're running **15**, comfortably over the minimum. -Those testers need the **unlocked / Full Version** without paying. Because premium is a -single RevenueCat entitlement (`premium`) the app reads, every method below unlocks the whole -app through the *same* code path — there is no separate "tester build." - -**Method 1 — RevenueCat promotional grant (recommended: no purchase, revocable).** -- In **RevenueCat → the tester's customer profile → Entitlements card → Grant**, pick - `premium` and a duration — **1 month** to cover the test window, or **Lifetime**. Granted - entitlements show as `rc_promo`, unlock everything immediately, and never charge the user. -- Bulk it with the REST API (Secret key): - `POST https://api.revenuecat.com/v1/subscribers/{app_user_id}/entitlements/premium/promotional` - with body `{"duration":"lifetime"}` — loop your 15 ids. -- Revoke anytime from the "…" menu on the granted entitlement, or let the duration lapse. -- **App User ID note:** to grant to a *specific* tester you need their RevenueCat App User ID. - Either have them launch the app once (they appear under an anonymous id you can grant), or - call `Purchases.logIn()` in the tester build so you can grant - ahead of time and keep the unlock stable across reinstalls. - -**Method 2 — Google Play license testers (free *test purchases*; exercises the real buy flow).** -- Play Console → **Settings → License testing** → add the 15 tester Gmail accounts (your own - publishing account is always a license tester). -- A tester taps **Buy** on the Full Version and completes it with a **test payment method** — - the dialog shows a "test purchase" notice and **no money is charged** (no tax computed). It - flows through Google Play Billing → RevenueCat → `entitlements["premium"].isActive`, so it - validates the exact path real buyers use. -- They must be on this list **in addition** to the closed-test tester list — track membership - alone does not make in-app purchases free. - -**Method 3 — Google Play promo codes (optional).** Play Console can issue **promo codes** for -the one-time `remove_ads` product (limited quantity per quarter); hand one to a tester to -redeem the Full Version free. Heavier to manage than Method 1 for a small cohort. - -**Not for the closed track — the debug override.** The shells include an internal -`TESTER_UNLOCK` debug override (`Billing.kt` / `Billing.swift`) that forces premium without a -purchase, but it is compiled-inert in release builds — and the closed-test track *is* a -release build. It only helps your own local QA, never the 15 testers. Use Method 1 or 2 for -them. (Codebase detail: brief §9a.) - -**iOS (ships ~1 week later).** Method 1 (RevenueCat grant) works unchanged; for the real buy -flow use **App Store sandbox testers** (App Store Connect → Users and Access → Sandbox) -and/or TestFlight. The grant path and the `premium` entitlement are identical across -platforms. - ---- - -## 6. AppLovin MAX (ad mediation) - -**Sign up** — https://dashboard.applovin.com -- [ ] Add your app **twice** — one entry for Android (`app.rustynes`) and one for iOS - (`app.rustynes`). If not yet live, add it manually by package/bundle id. -- [ ] Get the **SDK Key** (Account → General → Keys) — used in the init call. -- [ ] Create **Ad Units** per platform: an **Interstitial** and a **Rewarded** (both - required — the Rewarded unit powers the free-tier "+11 min per ad" play-time grant). - Copy each ad-unit id for the build. -- [ ] Enter your **payout + tax** info to receive ad revenue (note payout minimums / NET terms). -- [ ] MAX → Mediation → **Networks**: enable the recommended networks (Section 7). -- [ ] **Consent flow (EEA/UK/Switzerland):** enable MAX's **Terms & Privacy Policy flow** - (Google UMP) and publish a GDPR message in AdMob. A Google-certified CMP integrated with - the IAB TCF is **required to serve personalized ads** in the EEA/UK (since 16 Jan 2024) - and Switzerland (since 31 Jul 2024); the flow must complete **before** the SDK - initializes. Add every network you enabled to the consent message. (Brief §6g / §7.) - ---- - -## 7. Mediation network accounts (fill the MAX waterfall) - -For each network: create the account, create/add the app, link it into MAX (enter the -network's app id/keys in the MAX dashboard), and have the build include that network's -**adapter** (Gradle/SPM — see brief §7). iOS: add each network's **SKAdNetwork IDs**. - -- [ ] **Google AdMob** — https://admob.google.com — create account + app; copy the **AdMob - App ID** into the Android manifest and iOS `GADApplicationIdentifier`. Enables Google - bidding (usually top demand). Also lets you publish the GDPR/UMP consent message. -- [ ] **Meta Audience Network** — https://www.facebook.com/audiencenetwork — create app, link to MAX. -- [ ] **Unity Ads** — https://dashboard.unity.com — create project, link to MAX. -- [ ] **Pangle (ByteDance/TikTok)** — https://www.pangleglobal.com — create app, link to MAX. - -Expand later with Liftoff Monetize (Vungle), Mintegral, InMobi, or ironSource as you tune. - ---- - -## 8. Credentials handoff (what to give the build, and where it goes) - -Hand these to whoever wires the app (or drop into config). The skeleton already reads them. - -| Value | Source | Android (`gradle.properties`) | iOS (Info.plist / xcconfig) | -|---|---|---|---| -| AppLovin SDK key | AppLovin → Keys | `applovinSdkKey` | `APPLOVIN_SDK_KEY` | -| RevenueCat public key | RevenueCat (per platform) | `revenueCatGoogleKey` (`goog_…`) | `REVENUECAT_API_KEY` (`appl_…`) | -| MAX interstitial ad-unit id | AppLovin → Ad Units | `maxInterstitialAdUnitId` | `MAX_INTERSTITIAL_AD_UNIT_ID` | -| Rewarded ad-unit id (required) | AppLovin → Ad Units | `maxRewardedAdUnitId` | `MAX_REWARDED_AD_UNIT_ID` | -| AdMob App ID | AdMob | `AndroidManifest` `com.google.android.gms.ads.APPLICATION_ID` | `GADApplicationIdentifier` | - -Keep real keys out of source control (inject via `gradle.properties`/CI and an `.xcconfig`). - ---- - -## 9. Pre-submission compliance checklist - -(Full detail in the implementation brief §6–§7.) - -**Android** -- [ ] Native `.so` is **16 KB page-size aligned** (NDK r28+, AGP 8.5.1+) — Play blocks - non-compliant bundles. -- [ ] Play Billing **v8+** (RevenueCat bundles a compliant version). -- [ ] AD_ID permission declared; Data safety form complete; content rating set (Teen+). -- [ ] **Closed-test cohort can reach the Full Version** without paying — RevenueCat - promotional grant and/or **License testing** Gmail list configured (§5a). -- [ ] **Certified CMP + IAB TCF** consent (Google UMP via MAX's flow) integrated and GDPR - message published — required for personalized ads in the EEA/UK/CH; consent completes - **before** SDK init. - -**iOS** -- [ ] `NSUserTrackingUsageDescription` present; ATT requested **before** MAX init. -- [ ] **State in App Store Connect review notes that you use ATT** (omitting → rejection). -- [ ] SKAdNetwork IDs added (AppLovin Info.plist generator) + app `PrivacyInfo.xcprivacy` - + App Privacy nutrition labels. -- [ ] **Third-party SDK privacy manifests + signatures present** — ship current - AppLovin/adapter/RevenueCat versions; review runs binary checks (missing/unsigned → reject). -- [ ] EU users: TCF consent flow shown in addition to ATT. - -**Both** -- [ ] Emulator only — **no bundled copyrighted ROMs**; provide a file-import path for - user-owned ROMs/homebrew. Verify the current Apple Guideline 4.7 / Google emulator - policy text at submission. - ---- - -## 10. Realistic timeline - -- **D-U-N-S** (if org): up to ~a few days to a couple of weeks. -- **Apple org verification:** days to weeks (individual is usually faster). -- **Google identity verification:** hours to ~2 business days. -- **Google personal-account closed test:** **14 days minimum** + a review (~up to 7 days) - for production access — start this early or use an org account to skip it. -- **App review:** Apple typically a day or a few; Google often hours to a couple of days. - -Plan **2–4 weeks** of account/verification overhead before either store can go live — -mostly parallelizable with development. Begin the slow verifications (§2) on day one. - ---- - -## 11. Links - -- Apple enroll: https://developer.apple.com/programs/enroll/ -- Apple D-U-N-S: https://developer.apple.com/support/D-U-N-S/ -- App Store Connect: https://appstoreconnect.apple.com -- Apple Small Business Program: https://developer.apple.com/app-store/small-business-program/ -- Google Play signup: https://play.google.com/console/signup -- Google Play 12-tester rule: https://support.google.com/googleplay/android-developer/answer/14151465 -- RevenueCat: https://app.revenuecat.com — docs: https://www.revenuecat.com/docs -- RevenueCat connect App Store: https://www.revenuecat.com/docs/getting-started/installation/app-store-connect -- RevenueCat connect Play: https://www.revenuecat.com/docs/getting-started/installation/google-play-store -- AppLovin dashboard: https://dashboard.applovin.com -- AdMob: https://admob.google.com -- Meta Audience Network: https://www.facebook.com/audiencenetwork -- Unity dashboard: https://dashboard.unity.com -- Pangle: https://www.pangleglobal.com - -*Verify fees, commission rates, and policy text at the time you enroll — they move.* diff --git a/docs/monetization/pre-implementation-addendum.md b/docs/monetization/pre-implementation-addendum.md deleted file mode 100644 index 8bb62b2e..00000000 --- a/docs/monetization/pre-implementation-addendum.md +++ /dev/null @@ -1,477 +0,0 @@ -# RustyNES — Pre-Implementation Addendum: Optimizations, Enhancements & Ad Strategy - -**Audience:** Claude Code (and the engineer/owner). Read **after** `implementation-brief.md`. -This is the "resolve before you build and ship" layer: an ad-placement strategy tuned to a -*vintage-console emulator*, ad **scoping** for this app category, performance items that are -specific to running ads alongside a cycle-accurate emulator, and product enhancements. Where -a claim is load-bearing it cites a source in §9. - -> TL;DR for this app type: the retro-emulator audience is **ad-averse**. The free tier is -> **time-gated to 8 minutes** per game session (no save states, no battery saves); each -> **completed rewarded ad grants +11 minutes**, and a cheap one-time **Full Version / remove-ads** -> unlock removes the timer and unlocks saves. Lean on **rewarded** (opt-in) ads, keep -> interstitials sparing and never over the game, and suppress ads in the first session. -> Aggressive interstitials or subscriptions will tank reviews and retention for this category -> specifically. - ---- - -## 1. Read the market before designing the ads - -The flagship mobile emulators set user expectations, and that expectation is **no ads**: -- **RetroArch** ships free, open-source, and explicitly **ad-free**; its maintainers state - they have never burdened users with in-app ads, monetization SDKs, or paywalled features. -- **Delta** (No. 1 on the iOS charts at launch) is **free with no ads and no paywalls**, - funded by donations/Patreon. -- **Lemuroid** (Android, ~25 systems) is free and ad-free. -- When the iOS emulator category opened in 2024, several early entrants were publicly - criticized as **"ad-riddled"** with third-party trackers — the opposite of a good look. -- The single-system emulators that *do* monetize converge on a **light-ads + ~$4–5 one-time - "remove ads"** model (e.g. Mupen64's $4.99 remove-ads + cloud backups). Subscription-heavy - emulators (e.g. $5.49/week) are widely mocked. - -**Implication for RustyNES:** ads are *tolerated* but must be minimal and respectful. The -realistic, well-reviewed pattern is **rewarded-primary + sparing interstitials + a cheap -lifetime remove-ads**, with a genuinely good free tier so you compete with the ad-free -incumbents on quality, not just price. Treat the remove-ads IAP — not ad volume — as the -primary revenue lever. - ---- - -## 2. Recommended ad strategy for an emulator - -### 2a. Format mix (in priority order) -1. **Rewarded video — primary.** Opt-in, ~95%+ completion, highest eCPM ($15–30 tier-1), - and the only format the community broadly accepts because it's a *value exchange*. Gate - optional conveniences behind it (see 2c). -2. **Interstitial — sparing, transitions only.** Full-screen, decent eCPM ($12–25 video - tier-1), but the format most likely to wreck retention if mistimed. Use rarely and only - at true transitions (see 2b). -3. **Banner — avoid over the game; optional in menus only.** A banner over the emulated - viewport obscures a 256×240 screen and reads as user-hostile. If used at all, restrict to - the library/settings screens. Given the ad-averse audience, consider skipping banners. -4. **App-open — avoid.** High risk of interrupting a user resuming a game; easy to trip the - stores' disruptive-ads rules. Not recommended for this app type. - -### 2b. Where interstitials may fire (the only acceptable transition points) -Interstitials are appropriate only for apps with clear start/stop points; an emulator's -library↔game transitions qualify. Show **only**: -- On **exit from a game back to the library** (a natural stopping point), shown **before** - the library renders, not after. -- Optionally when **navigating between major library sections**. - -**Never** show an interstitial: -- on app launch or cold start; -- when **entering/starting** a game (don't make someone wait to play); -- mid-gameplay or mid-frame, ever; -- over a **resumed** game (app foreground); -- on app exit. - -### 2c. Rewarded design — the free-tier engine (the format to invest in) - -The free tier is **time-gated**: a free user gets a base **8-minute** play budget per game -session, **no save states**, and **no battery-backed (SRAM) saves**. When the budget runs -out, the emulator pauses and the user is offered two choices — **buy the Full Version** -(removes the timer and unlocks save states + battery saves) or **watch a rewarded ad for +2 -minutes** of play. - -**Primary rewarded mechanic — "earn more time":** every rewarded ad the user **completes** -(watched for the duration the ad network requires) grants **+11 minutes** of play time. Grant -the time **only** from the ad network's reward callback — AppLovin `OnUserRewarded` (Kotlin) -/ `didRewardUser` (Swift) — **never** on ad load, show, or dismiss, so the grant maps exactly -to a qualifying view. This is the ideal pattern for this audience: strictly opt-in, a clear -value exchange, and it makes the timer feel fair rather than punitive. It also makes rewarded -— not interstitial — the dominant free-tier ad surface, which is exactly where this category -tolerates ads. - -Notes: -- The +11 min grant is **capped at 2 rewarded ads per game session** — a maximum of - **+22 minutes**, so a fully ad-engaged free user reaches **30 minutes** total (8 base + 22). - Each of the 11 is a paid rewarded impression. Once the cap is hit, **stop offering the - rewarded option** and present **only** the Full Version prompt — this converts the most - engaged free users toward the purchase. The cap resets at the start of each game session. -- This rewarded flow is **separate from the interstitial cadence** in §2d: it is - user-initiated and **not** subject to the interstitial interval. -- Reuse the same rewarded mechanic for **optional conveniences** as a try-before-you-buy - funnel — a session unlock of fast-forward/turbo, an extra save-state slot, a shader/CRT - trial, cheat access — the permanent versions of which the Full Version IAP unlocks. -- Premium users never see the timer, the run-out prompt, or any offer. - -### 2d. Cadence — recommended defaults (conservative for this audience) -Industry norms are 2–3 min intervals and 4–6 interstitials/session; for an *immersive, -ad-averse emulator* go gentler: -- **First session: zero interstitials.** A 60–90s first-ad delay improves D1 retention - 5–8% with negligible revenue loss; for this audience, suppress the whole first session. -- **Minimum interval: ≥ 4 minutes** between interstitials (emulator sessions are long). -- **No per-session interstitial count cap** — pacing is purely the interval + first-session - suppression + the premium check (the cadence is deliberately gentle already). -- **Segment by session depth:** a first-week user sees fewer ads than a veteran. -- **Premium users: zero ads** (already enforced by the core). -- Keep all of this **remotely tunable** (see §5) so you can adjust without a new build. - -### 2e. Codify cadence in the shared core (extend `AdPolicy`) -The existing `AdPolicy` already handles premium suppression, launch grace, and the interval. -Add session-awareness so the rules above live in one shared, tested place: - -```rust -// Extends crates/rustynes-monetization/src/monetization.rs — add to AdConfig and AdPolicy. -// New AdConfig field: -// suppress_first_session: bool // true → no interstitials during session #1 -// -// New State field: -// session_index: u32 // 1 on first ever launch, incremented per session - -#[uniffi::export] -impl AdPolicy { - /// Host calls at the start of each app session, passing the persisted session count. - 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; - } - - // Augment should_show_interstitial with one extra gate (before the interval check): - // if cfg.suppress_first_session && s.session_index <= 1 { return false; } - // - // There is NO per-session interstitial count cap — pacing is interval + first-session - // suppression + the premium check only. -} -``` -The host persists `session_index` (e.g. SharedPreferences / UserDefaults) and passes it to -`begin_session` at launch. Everything else stays host-agnostic and unit-testable. - -### 2f. Free-tier play-time budget + rewarded extension (core) - -The 8-minute timer and the "+11 minutes per ad" grant are monetization logic, so they live in -the shared core — one tested implementation for both platforms. **This is now implemented in -`crates/rustynes-monetization/src/monetization.rs`** (13 unit tests pass; Kotlin/Swift bindings regenerated). The -`AdConfig` and `AdPolicy` additions are: - -```rust -// Further AdConfig fields: -// base_play_ms: u64 // free-tier base budget per game session (480_000 = 8 min) -// reward_play_ms: u64 // play time per COMPLETED rewarded ad (660_000 = 11 min) -// max_reward_grants_per_session: u32 // cap on rewarded extensions per game session (11) -// -// Additional State fields: -// budget_ms: u64 // total granted budget for the current game session -// consumed_ms: u64 // active (unpaused) play time consumed this session -// reward_grants_this_session: u32 // how many +11-min grants already given this session - -#[uniffi::export] -impl AdPolicy { - /// Call when a game starts. Free users get base_play_ms and a fresh grant counter; - /// premium is unlimited. - pub fn start_play(&self) { - let mut s = self.state.lock().unwrap(); - s.budget_ms = self.cfg.base_play_ms; - s.consumed_ms = 0; - s.reward_grants_this_session = 0; // reset the 2-grant cap each game session - } - - /// Host reports ACTIVE play time elapsed (e.g. once per second of unpaused emulation). - /// The host already pauses for ads/background, so it simply stops calling this while - /// paused — the core stays pause-agnostic and deterministic (no clock read inside). - 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 "+11 min" offer should be shown right now: free user, under the - /// per-session cap. Once false, present ONLY the Full Version prompt. - 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 - } - - /// Remaining rewarded extensions this session (for UI like "3 ad-extensions left"). - pub fn reward_grants_remaining(&self) -> u32 { - let s = self.state.lock().unwrap(); - self.cfg.max_reward_grants_per_session.saturating_sub(s.reward_grants_this_session) - } - - /// Grant +reward_play_ms, enforcing the per-session cap. Call ONLY from the rewarded - /// reward callback (OnUserRewarded / didRewardUser) — never on load/show/dismiss. - /// Returns true if the grant was applied, false if the cap was already reached. - 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 - } - - /// May the user keep playing right now? Premium is always allowed. - 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; None = unlimited (premium). Drive the on-screen - /// countdown from this. - 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)) } - } -} -``` - -With `max_reward_grants_per_session = 2` and `reward_play_ms = 660_000`, a fully ad-engaged -free user reaches **8 + (2 × 11) = 30 minutes** maximum per game session. - -Host flow (both platforms, identical because the logic is in the core): -1. On game start → `start_play()`. -2. Each second of **unpaused** emulation → `add_active_time(1000)`; read - `play_time_remaining_ms()` to update the countdown UI. -3. When `is_play_allowed()` returns false → **pause the emulator** and present the run-out - prompt. Offer *Watch ad for +11 min* **only if `can_offer_rewarded()`** (i.e. under the - 2-grant cap); otherwise show **only** *Buy Full Version*. Use `reward_grants_remaining()` - to label the option (e.g. "3 left"). -4. On the rewarded **reward callback** → `grant_rewarded_time()` (it returns false and is a - no-op once the cap is hit), then resume. -5. A completed purchase sets premium → `is_play_allowed()` is always true; the timer and - prompt disappear immediately. - -Unit tests to add alongside the existing ones: budget exhausts after `base_play_ms` of active -time; a reward grant adds exactly `reward_play_ms`; the **11th grant succeeds and the 12th -returns false / is a no-op**; `can_offer_rewarded()` flips to false at the cap; premium is -always allowed and `play_time_remaining_ms()` returns `None`; paused time (no `add_active_time` -calls) does not -consume budget. - ---- - -## 3. Scoping ad *content* for an all-ages retro audience - -NES-era games attract nostalgic adults **and** kids, even though the app is rated Teen and is -**not** child-directed (AppLovin forbids child-directed apps — see brief §6). Keep the ads -themselves age-appropriate: -- **AdMob / Google bidding:** call `setMaxAdContentRating()` (G / PG / T / MA) on the Mobile - Ads SDK `RequestConfiguration` **before** init, or set it per-app in the AdMob UI. Ratings - are cumulative (T allows G/PG/T, blocks MA). **Recommend PG or T.** -- **AppLovin network:** in MAX → Ad Review → Manage → Applications → **Ad Filtering**, enable - the **Dating Ads** and **Mature Audiences (17+)** filters. -- **Other mediated networks (Meta, Unity, Pangle):** each enforces content rating in **its - own dashboard** — set the equivalent cap there too; MAX-side filtering only covers AppLovin. -- Do **not** tag for child-directed treatment (that would forbid AppLovin and gut fill); - the goal is an all-ages-safe ceiling, not COPPA child mode. - -**Revenue expectations** (so the model is realistic, tier-1): interstitial video ~$12–25 -eCPM, rewarded ~$15–30. With the conservative cadence above, ad ARPDAU will be modest — the -remove-ads IAP is the real lever. - ---- - -## 4. Performance items to resolve before release (emulator-specific) - -A cycle-accurate NES core runs a hard real-time loop (~1.79 MHz CPU model, 60 Hz frame -pacing, continuous APU sample generation). Ad SDKs are heavy, asynchronous, and network- and -GPU-bound. The two must be **isolated**. - -- **Thread isolation (critical):** run emulation on a dedicated high-priority thread; do all - ad SDK work (load/show, mediation, video decode) on the **main/UI thread**. Never call ad - SDK methods from the emulation loop. The monetization FFI calls (`should_show_*`, - `notify_*`) are cheap but should still be invoked at frame boundaries / transitions, not - mid-frame. -- **Pause/resume semantics:** when an interstitial or rewarded ad displays, **pause the CPU, - PPU, APU, and audio output**, release audio focus, and snapshot enough state to resume - cleanly. Resume on ad dismissal. Verify a save-state is never lost to an ad or an - app-background triggered by an ad click. Wire this to the same lifecycle path as - phone-call/background interruptions. -- **Preload, but don't contend:** cache the next interstitial during idle/menu time so the - show is instant (a spinner is worse than no ad). Do the network load on a background - thread so it never steals cycles from the emulation thread. Reload immediately after each - ad is dismissed. -- **Cold-start latency:** initializing MAX + several adapters at launch adds startup cost. - Initialize the ad SDK **off the critical path** (after the first frame / library is - interactive) but early enough to cache an ad before the first eligible transition. Measure - time-to-interactive with and without ad init. -- **Binary size & memory:** MAX plus a handful of mediation adapters adds meaningful size and - RAM (commonly several to ~15+ MB). A retro emulator is expected to be lightweight, so keep - the adapter set lean (the recommended four — Google, Meta, Unity, Pangle — not every - available network) and measure APK/IPA delta. Premium users should ideally **never - initialize** the ad SDK at all. -- **Battery / thermal:** video ads and emulation both draw power; avoid back-to-back ads and - honor the conservative caps to prevent thermal throttling that would also hurt emulation - frame pacing. -- **Render-surface interaction:** the emulator presents on a GL/Vulkan (Android) / Metal - (iOS) surface. Interstitials/rewarded are full-screen and present over it cleanly; if you - ever add a menu banner, mind z-order and the `SurfaceView` vs `TextureView` choice on - Android. Avoid ad-SDK GC churn on the render thread (jank). Confirm the **16 KB page-size - alignment** for the Rust `.so` (brief §6a) — unrelated to ads but a hard release gate. -- **Consent flow timing:** show the UMP/ATT consent flow without blocking the user from - reaching the library and starting a game; gather consent, then init/cache ads in the - background. - ---- - -## 5. Product enhancements to consider - -- **Cheap one-time "Remove Ads / Full Version" (~$3.99)** as the *primary* monetization, - matching the category norm. Likely converts better here than a subscription. -- **Remote-configurable cadence:** source `AdConfig` (interval, first-session suppression) from - RevenueCat metadata or Firebase Remote Config so you can tune pacing post-launch without - shipping a build. -- **RevenueCat Paywalls (RevenueCatUI):** dashboard-configured paywall instead of hand-built - purchase UI; A/B testable. -- **House ads / cross-promo:** use AdMob house ads (free) to promote the Full Version unlock - inside the rewarded/interstitial inventory you control. -- **Impression-level ad revenue (ILRD):** forward MAX per-impression revenue to analytics to - compute true LTV and to A/B cadence variants against retention. -- **"Support the developer" framing:** given this community donates to ad-free apps, consider - positioning the IAP partly as support, and keep the free tier genuinely good. -- **Compete on quality, not just price:** the ad-free incumbents win on features — save - states, **CRT/shader filters**, controller support, achievements. A strong free tier is - what makes light ads forgivable. Decide which of these are free vs. paywalled (the - `PremiumFeature` enum) — see brief §10. - ---- - -## 6. Decisions to resolve before coding - -- [ ] Confirm **rewarded-primary + sparing-interstitial** mix (vs. interstitial-heavy). -- [ ] Confirm the **free-tier model**: 8-min base play budget, no save states, no - battery-backed saves (decided — see §2c). -- [ ] Confirm the **rewarded "+11 min" grant** and the **2-grant per-session cap** - (max +22 min → 30 min total) — decided; confirm the values. -- [ ] Confirm **base play minutes (8)** and **reward minutes (11)**, and whether they're - remote-configurable at launch. -- [ ] Confirm **no banners** (or menu-only). -- [ ] Lock the **starting cadence** (see §7) and whether it's remote-configurable at launch. -- [ ] Set the **max ad content rating** target (PG vs T) across all networks. -- [ ] Confirm the **remove-ads / Full Version IAP** type (one-time non-consumable) and price (~$3.99). -- [ ] Finalize the **`PremiumFeature` set** — at minimum `SaveStates` and `BatterySaves` - (unlimited play is implied by premium); optionally `FastForward`, shaders, cheats. -- [ ] Confirm whether premium users **skip ad-SDK initialization** entirely. - ---- - -## 7. Recommended starting configuration (baseline) - -A concrete, conservative baseline to implement first, then tune via remote config: - -| Setting | Value | -|---|---| -| **Free-tier play budget (base)** | **8 min** per game session | -| **Play time per completed rewarded ad** | **+11 min** | -| **Rewarded extension cap** | **2 per session** (max +22 min; 30 min total) | -| **Free tier excludes** | Save states, battery-backed (SRAM) saves | -| First session | **No interstitials** (`suppress_first_session = true`) | -| Launch grace (sessions 2+) | 90 s | -| Min interval between interstitials | **240 s (4 min)** | -| Session cap | **None** (paced by interval + first-session suppression only) | -| Interstitial placement | On **game→library exit only** | -| Rewarded | Opt-in; **primary use = +11 min play time**; also gates fast-forward / extra save-state / shaders / cheats | -| Banners | **None** (or menu screens only) | -| App-open ads | **None** | -| Max ad content rating | **PG** (raise to T only if fill is too low) | -| Premium | **Unlimited play + save states + battery saves**; zero ads; skip ad-SDK init if feasible | -| Remove-ads / Full Version IAP | One-time non-consumable, ~$3.99 | - -This maps onto the `AdConfig` defaults plus the §2e session fields. Keep the values in remote -config from day one so you can A/B without a release. - ---- - -## 8. How this changes the existing skeleton - -- ✅ **Done:** `crates/rustynes-monetization/src/monetization.rs` now implements §2f (`start_play`, `add_active_time`, - `grant_rewarded_time` → bool with the 2-grant cap, `can_offer_rewarded`, - `reward_grants_remaining`, `is_play_allowed`, `play_time_remaining_ms`, plus - `base_play_ms` / `reward_play_ms` / `max_reward_grants_per_session` in `AdConfig`), the - `PremiumFeature` enum gates `SaveStates` + `BatterySaves` + `FastForward`, all 13 unit tests - pass, and the Kotlin/Swift bindings are regenerated (`core/generated/`). -- ⬜ **Remaining (shell wiring):** add a **`MaxRewardedAd` / `MARewardedAd`** gate in each - shell, mirroring `AdGate`. On the reward callback (`OnUserRewarded` / `didRewardUser`), call - **`grantRewardedTime()`** then resume the emulator. -- ⬜ **Remaining (UI):** a countdown driven by `playTimeRemainingMs()`, and a **run-out prompt** - ("Buy Full Version" / "Watch ad for +11 min") shown when `isPlayAllowed()` is false, with the - emulator paused behind it; offer the rewarded option only while `canOfferRewarded()` is true. -- ⬜ Drive `addActiveTime(...)` only from **unpaused** emulation (stop during ads, the run-out - prompt, and app background) so paused time never burns the budget. -- ⬜ Optional: §2e session-cadence extension (`begin_session`, first-session suppression) is - still a documented sketch, not yet in the core. -- ⬜ Add `setMaxAdContentRating` (Android, before MAX/AdMob init) and the AppLovin Ad Filtering - + per-network content caps (§3). -- ⬜ Ensure the emulator's pause/resume path is invoked on ad display/dismiss (§4). - ---- - -## 9. Live-tuning the policy via remote config - -`AdConfig` is injected at construction, so every pacing and free-tier value can be sourced -from a remote config and changed **without an app update or rebuild**. This is what lets you -find the revenue/retention balance after launch instead of guessing it before. - -Expose these fields (all already in `AdConfig`): - -| Field | Default | Typical tuning range | -|---|---|---| -| `base_play_ms` | 480 000 (8 min) | 300 000–900 000 (5–15 min) | -| `reward_play_ms` | 660 000 (11 min) | 300 000–900 000 (5–15 min) | -| `max_reward_grants_per_session` | 2 | 0–4 (0 disables the rewarded extension) | -| `min_interval_ms` | 240 000 (4 min) | 120 000–360 000 | -| `launch_grace_ms` | 30 000 | 0–120 000 | - -Pattern: -- Source values from **RevenueCat** offering metadata or **Firebase Remote Config**. -- Start from `default_ad_config()` as the safe baseline; overlay any fetched values; build - `AdPolicy` with the merged config. **Never block startup on the fetch** — if it fails or - the device is offline, run on the defaults (this also fixes the offline case in - `recommendations.md`). -- **Clamp every remote value in the host** to a sane range before passing it in, so a bad - remote push can't brick the gate (e.g. a 0-ms base budget). - -Experiments: A/B the base budget (5 vs 8 min), reward minutes, and the cap via RevenueCat -Experiments or Remote Config conditions; read the outcome from the §10 funnel. Pair with -RevenueCat price/paywall experiments and MAX waterfall A/B once you have baseline data. - ---- - -## 10. Analytics — the monetization funnel to instrument - -RevenueCat shows purchases and MAX shows ad revenue, but the decisions you most need to tune -live *between* them: the timer → rewarded → purchase funnel. Log it yourself (Firebase / -Amplitude / PostHog). Emit these from the shells at the points the core surfaces: - -| Event | Fire when | Useful properties | -|---|---|---| -| `session_start` | `start_play()` | `game_id`, `is_premium` | -| `play_runout` | `is_play_allowed()` → false | `session_seconds`, `grants_used` | -| `rewarded_offered` | run-out prompt shows the ad option | `grants_remaining` | -| `rewarded_completed` | reward callback → `grant_rewarded_time()` true | `grant_index` | -| `rewarded_failed` | rewarded load/show failed (no fill, offline) | `reason` | -| `reward_cap_reached` | `can_offer_rewarded()` → false at run-out | `session_seconds` | -| `paywall_shown` | Full Version prompt shown | `trigger` = runout \| cap \| menu | -| `purchase_completed` / `purchase_restored` | RevenueCat success | `product_id` | -| `interstitial_shown` | `notify_interstitial_shown()` | `session_index` | -| `ad_revenue` | MAX impression-level revenue (ILRD) callback | `revenue`, `network`, `format` | - -Headline metrics to watch: share of sessions that hit run-out; rewarded **accept rate** -(offered → completed); **cap-hit rate**; **cap-hit → purchase** conversion (the core -monetization signal); ARPDAU; and rewarded fill/latency. Forward `ad_revenue` to RevenueCat -too, so LTV and the ad-vs-no-ad cohorts are computed in one place. - -Privacy: any of this that counts as tracking must sit behind the same consent as ads (ATT on -iOS, TCF on EU Android — brief §6b/§6g), use non-PII identifiers, and be declared in the iOS -privacy manifest / Play Data safety form. - ---- - -## 11. Sources - -- Interstitial placement & frequency: https://support.google.com/admob/answer/6066980 , - https://support.google.com/admob/answer/6201350 , https://www.publift.com/blog/interstitial-ads-a-best-practice-guide-for-publishers , - https://adreact.com/blog/interstitial-ad-best-practices-mobile-games/ , - https://adapty.io/blog/mobile-interstitial-ads/ -- Ad content rating controls: https://support.google.com/admob/answer/7562142 , - https://developers.google.com/admob/android/targeting , - https://support.applovin.com/en/max/faq/stop-unwanted-or-inappropriate-ads -- Emulator market/monetization context: https://techcrunch.com/2024/05/03/retro-game-emulator-delta-app-store-ios/ , - https://www.libretro.com/ , - https://www.gamesradar.com/platforms/iphone/after-weeks-of-ad-riddled-apps-and-bizarre-delistings-retroarch-is-finally-on-the-app-store-to-handle-all-your-retro-emulation-needs-for-free/ , - https://mobilesyrup.com/2024/05/05/fake-delta-app-android-best-emulators/ - -*Verify SDK APIs, eCPM ranges, and store policy text at implementation time — they move.* diff --git a/docs/monetization/recommendations.md b/docs/monetization/recommendations.md deleted file mode 100644 index a0fcec98..00000000 --- a/docs/monetization/recommendations.md +++ /dev/null @@ -1,154 +0,0 @@ -# RustyNES — Recommendations & Considerations (Mobile Monetization) - -Considerations that go *beyond* the baseline design in the other docs: design holes in the -free-tier mechanic worth closing before launch, engineering-robustness items, and -store/product polish. The free-tier model itself (8-min budget, +11 min per rewarded ad, -2-grant/+22-min cap → 30 min max, no save states, no battery saves) is decided and -implemented in `crates/rustynes-monetization/src/monetization.rs`; this document is about the edges around it. - -Two adjacent topics live in other docs to keep them next to the work they affect: -**compliance/consent** (the EEA/UK/CH certified-CMP requirement and iOS SDK privacy -manifests) is in `platform-setup-runbook.md` §6/§9 and `implementation-brief.md` §6d/§6g/§7; -**live-tuning + analytics** (remote-config fields and the monetization funnel) is in -`pre-implementation-addendum.md` §9/§10. Pointers in §5 below. - -> TL;DR: the timer + rewarded loop is sound, but four edges will bite if left implicit — a -> free user can restart to dodge the timer, an **offline** free user dead-ends with no ad and -> no purchase path, "**no saves at all**" is a 1-star-review risk, and gating the **very first -> session** kills first-impression conversion. Close those, keep the ad SDK off the -> emulator's critical path, and put a real paywall at the run-out moment. - ---- - -## 1. Free-tier mechanic — design holes to close - -These are specific to the timer + rewarded model and are the ones most likely to surprise you -in the wild. - -### 1a. Session-reset abuse + session semantics -The budget and grant counter reset on every `start_play()`, so a free user can simply kill -and relaunch a game to get a fresh 8 minutes without watching a single ad. What *saves* this -design today is the no-save-states rule — restarting loses their run — so decide it -consciously rather than by accident. Define crisply when `start_play()` fires: a **session = -continuous play of one ROM from load to unload**; pausing to the menu and resuming the same -ROM does **not** reset it; switching ROMs does. If you want to harden against restart-farming, -either persist the in-progress play state across relaunch (§1f) or add a soft **daily** -free-minutes budget on top of the per-session budget. - -### 1b. The offline trap (high priority) -A free user offline — plane, subway, no signal — hits the 8-minute wall with **no way out**: -rewarded ads need the network to load, and the purchase flow needs it too. Don't let that -dead-end read like a bug: -- The grant only fires on the reward callback, so a failed/again-no-fill load means *nothing - happens* — the run-out prompt must explicitly handle "no ad available right now." -- Consider granting a small **offline grace** continuation, or surfacing a clear message, so - the session degrades gracefully instead of locking. -- Sourcing `AdConfig` from remote config with safe local defaults (addendum §9) means an - offline launch still runs on `default_ad_config()` rather than stalling. - -### 1c. The no-save cliff is a review risk -"8 minutes, then you lose everything, and you can never save" is a sharp edge for an RPG or a -long platformer — exactly the audience that leaves angry reviews about it. Consider giving the -free tier **one auto-resume slot** (restores the current game on relaunch — not a true -multi-slot save-state), while keeping real save-states (`PremiumFeature::SaveStates`) and -battery-backed cartridge persistence (`PremiumFeature::BatterySaves`) premium. It softens the -cliff without giving away the headline premium feature. - -### 1d. Protect the first impression -You already suppress interstitials in the first session; extend that philosophy to the timer. -Gating a brand-new user at 8 minutes on their very first game — before they're hooked — hurts -conversion and retention. Give the first session a longer (or ungated) budget so value lands -before friction. This can be a remote-config condition (addendum §9), so you can tune the -first-run generosity without a release. - -### 1e. Preload the rewarded ad before run-out -Kick off a rewarded ad load at roughly **60–90 s remaining** (read from -`play_time_remaining_ms()`) so that when the user taps "Watch ad for +11 min" it plays -instantly instead of showing a spinner at the worst possible moment. Gate the offer on -`can_offer_rewarded()` and label it with `reward_grants_remaining()`. - -### 1f. Persisting in-progress play state (optional) -The core is in-memory: it forgets `consumed_ms` and `reward_grants_this_session` when the -process dies. If you want the timer/cap to survive a kill-and-relaunch mid-run (closing the -§1a hole), add a small serialize/restore hook to the core and have the host persist it -(SharedPreferences / UserDefaults), restoring it before the next `start_play()` of the same -ROM. Skip this if you accept restart-to-reset as intended behavior. - -### 1g. The grant is client-trusted -A rooted/jailbroken user can spoof the reward callback that calls `grant_rewarded_time()`. At -a one-time ~$3.99 price point this is fine to accept. If it ever matters, AppLovin supports -**server-side reward verification** for rewarded ads. - ---- - -## 2. Engineering robustness - -- **Never block the emulator on the ad SDK.** If MAX init fails or stalls, the app must still - play (degrade to no-ads). Defer ad-SDK init off the cold-start critical path; a premium user - can skip ad-SDK init entirely. -- **Tick `add_active_time` coarsely** — once per second of unpaused emulation, not per frame — - to avoid 60 Hz mutex contention on the high-priority emulation thread. A useful property of - the current design: the budget is **tick-derived, not wall-clock-derived**, so it is immune - to the `SystemClock.elapsedRealtime()` (counts sleep) vs `DispatchTime` uptime (does not) - discrepancy. Stop ticking while paused (ads, run-out prompt, background) and paused time - never burns budget. -- **Test-mode ads only during development.** Clicking your own *live* ads is the fastest way - to get an AppLovin/AdMob account banned. Use MAX's Mediation Debugger test mode and the - RevenueCat sandbox; never click production ads. -- **Cross-platform entitlement won't transfer for free.** A one-time IAP bought on Android - does **not** make the user premium on iOS (or vice versa) unless you add account linking / - login (RevenueCat with a shared app user id). For a solo launch, per-store purchases are - normal — just decide it explicitly and make **Restore Purchases** prominent so a user who - reinstalls or switches devices on the *same* store recovers their unlock. The same RevenueCat - identity matters when granting testers the unlocked build by App User ID (runbook §5a). - ---- - -## 3. Store & product polish - -- **Put a real paywall at the run-out moment** — that's your highest-intent conversion point. - RevenueCat Paywalls (brief §8b) render it natively; lead with the value: *unlimited play + - save states + battery saves*. Tag the trigger (`runout` vs `cap` vs `menu`) for analytics - (addendum §10). -- **Don't show a constant ticking countdown.** A hostile clock for the whole session hurts the - feel and the reviews. Surface remaining time subtly, and make it prominent only in the last - minute or at run-out. -- **Android binary size / ABIs.** Ship per-ABI splits via `cargo-ndk` (arm64-v8a primary; - add armeabi-v7a / x86_64 only as needed) inside the App Bundle, and keep the mediation - adapter set lean — the ad SDK + adapters add several MB and ship in the binary even for - premium users (you can't strip them per-user; you *can* skip initializing them). -- **Emulator review hygiene** (the recurring reason these apps get pulled): ship the emulator - only — **no bundled copyrighted ROMs** — provide a file-import path for user-owned - ROMs/homebrew (SAF on Android, `UIDocumentPicker` on iOS), and keep any "where to get games" - guidance pointed at legal homebrew/public-domain sources. Verify the current Apple Guideline - 4.7 and Google emulator-policy text at submission (brief §6f). - ---- - -## 4. Priority ordering (if you only do some of this) - -1. Offline trap (§1b) and the run-out "no ad available" path — these are correctness, not - polish; without them an offline user looks broken. -2. First-session generosity (§1d) and the run-out paywall (§3) — these move conversion the - most. -3. No-save auto-resume slot (§1c) — biggest lever on reviews/retention for the genre. -4. Session semantics + restart hardening (§1a/§1f) — decide now even if you defer the code. -5. Robustness items (§2) — cheap insurance; do them as you wire the shells. - ---- - -## 5. Covered elsewhere (so this doc isn't duplicated) - -- **Consent / privacy compliance (release blockers):** EEA/UK/Switzerland certified CMP + - IAB TCF, and iOS third-party SDK privacy manifests + signatures — see - `implementation-brief.md` §6d/§6g/§7 and `platform-setup-runbook.md` §6/§9. iOS ATT is in - brief §6b. -- **Live-tuning via remote config** (the `AdConfig` field list, clamping, experiments) and - the **analytics funnel** (events, headline metrics) — see - `pre-implementation-addendum.md` §9 and §10. -- **The free-tier core model and host flow** (`start_play` … `grant_rewarded_time` … the - 2-grant cap) — see `pre-implementation-addendum.md` §2c/§2f and the FFI surface in - `build-and-bindings.md`. - -*Verify SDK APIs, store policy text, and consent/attribution requirements at implementation -time — they move.* diff --git a/docs/monetization/rustynes-integration.md b/docs/monetization/rustynes-integration.md deleted file mode 100644 index 268b169b..00000000 --- a/docs/monetization/rustynes-integration.md +++ /dev/null @@ -1,224 +0,0 @@ -# RustyNES — Monetization Integration Notes (real-repo mapping) - -How this monetization layer maps onto the **actual** RustyNES source (`README` + the workspace + -`to-dos/plans/v1.8.0-android-plan.md`). The architecture, crate, determinism, and toolchain facts -below come straight from that plan and apply regardless of monetization model; the **monetization -model itself is a deliberate maintainer choice** (see the banner). - -> ## Monetization model: ad-supported freemium (PRIMARY — chosen) -> This project's **RevenueCat + AppLovin MAX** model is the **primary, chosen** path, a deliberate -> maintainer override of the ad-free default sketched in `v1.8.0-android-plan.md`: -> - **Free Google Play download** with a one-time non-consumable **"Full Version / Remove Ads"** -> IAP at **$3.99**, keyed to the **RevenueCat `premium`** entitlement (RevenueCat wraps Play -> Billing / StoreKit, so a single entitlement drives both platforms). -> - **Free tier = ad-supported**: **interstitials** at natural breaks (paced by a launch grace + -> a 4-min interval — **no per-session count cap**) plus **rewarded ads** that extend an 8-minute -> play session **+11 min each, capped at 2 grants → 30 min**. The three persistence features -> (§4) stay paywalled. -> - Purchasing the Full Version removes ads + the timer and unlocks the persistence features. -> -> The full interstitial + rewarded + entitlement surface in `core/` is **in scope and primary**. -> The plan's ad-free $2.99 `full_unlock` direct-Billing variant is the road **not** taken; it is -> noted here only where it explains a plan reference. - ---- - -## 0. Version & launch-timing reality - -Public `main` is **v1.5.0 "Lens"** (v1.6.0 "Studio" in dev); the Android app is the planned -**v1.8.0** milestone and iOS **v1.9.0**. Per the maintainer's 2026-06-20 timing decision: -- **v1.8.0–v1.8.7 are GitHub-Releases / sideload-only and ship full-featured** (no demo / no ads). -- **v1.8.8 is the planned Google Play debut** — the polished build that flips the freemium layer - on. So "v1.8.8" is a real, meaningful target, even though no v1.8.8 *tag* exists yet. -- The freemium layer (ads + the entitlement gate) is gated behind a **`PLAY_BUILD`** switch (a - `play` product flavor / `BuildConfig` flag, default **false**): sideload/dev/GitHub → no ads, - full-featured; the Play AAB → freemium. Wire the flavor in **v1.8.2** so every interim sideload - release stays full-featured while the freemium code rides along dormant until v1.8.8. - -The **15-tester / 14-day closed test** (runbook §5a) therefore applies to the **v1.8.8 Play debut**. - ---- - -## 1. Crate naming & placement *(fixed in this repo)* - -RustyNES already ships a workspace crate named **`rustynes-core`** (Bus/scheduler/console/save -states), so this crate is named **`rustynes-monetization`** (lib `rustynes_monetization`), Swift -module `RustyNesMonetization`, Kotlin package `com.doublegate.rustynes.monetization.ffi`. The locked plan also factors two -new mobile host crates you should slot beside: -- **`rustynes-mobile`** — a platform-agnostic **UniFFI control surface** over the core (load ROM - from a byte buffer, set the per-port `Buttons` mask, run-frame, borrow the framebuffer, save/load - state, query status), reused by both Android and iOS. -- **`rustynes-android`** — thin hand-rolled `jni` 0.21 glue (hand the `ANativeWindow` to wgpu, the - audio sink) that UniFFI can't express. - -Placement: keep `rustynes-monetization` a **mobile-only** member, off the desktop/wasm/default build -(it pulls UniFFI). Aligned to the workspace toolchain — **edition 2024, Rust 1.96** — and passes the -four gates (`fmt`, `clippy -D warnings`, `doc`, `test`). - ---- - -## 2. Where the monetization *policy* lives - -With the ad-supported model chosen, the policy lives in **shared Rust** — the `AdPolicy` object in -this `rustynes-monetization` crate, beside `rustynes-mobile` — so Android and iOS run **one** -implementation of the ad cadence, the rewarded play-time gate, and the feature gates (the same -cross-platform-share rationale the plan uses for `rustynes-mobile`). The native shells own only the -*plumbing*: -- **RevenueCat** → the `premium` entitlement → fed into the core via `set_premium(isActive)`. -- **AppLovin MAX** → loads/shows interstitials + rewarded ads; on the *reward* callback the shell - calls `grant_rewarded_time()`, and on display it calls `notify_interstitial_shown(now)`. -- The shell asks `should_show_interstitial(now)`, `is_play_allowed()`, `play_time_remaining_ms()`, - `can_offer_rewarded()`, and `feature_enabled(...)` and renders accordingly. - -The **single source of truth** holds: premium flows in only via `set_premium`, fed from RevenueCat -(plus the debug `TESTER_UNLOCK` override for on-device QA — the analogue of the plan's -`debug.force_unlocked`). The 13 unit tests pin the cross-platform contract. - -(For contrast: the plan's ad-free variant would instead put a `BillingManager` + demo timer in -Kotlin/Swift. That's the road not taken; the shared-Rust policy here is the chosen approach.) - ---- - -## 3. Architecture (LOCKED: hybrid — not winit/egui) - -The ship target is **Hybrid**: keep the existing **wgpu** renderer drawing the NES image onto an -Android **`SurfaceView`** (`ANativeWindow` → wgpu), run the core + render/audio loop on a dedicated -native thread (the Android analogue of the desktop `emu-thread`, `Arc>`), and let -**Jetpack Compose** own all chrome — top bar, settings, SAF ROM picker, **the touch overlay**, the -save-state manager, **and the ad/timer HUD + the "Remove Ads — Full Version ($3.99)" / "Restore" -sheet** (or a RevenueCat paywall). iOS (v1.9.0) mirrors this with **SwiftUI + wgpu→Metal** over the -same bridge. AppLovin's interstitial/rewarded views render as native overlays above the surface. - -This **corrects the earlier draft of this doc**, which recommended reusing `rustynes-frontend` via -winit/egui and avoiding Compose. Per the lock: winit+wgpu+egui on `android-activity` is only the -**beta.1 first-boot spike** (and an optional in-surface power-user/debugger overlay); the shippable -app uses **Compose**, and pure-Compose-without-wgpu was explicitly **rejected** (it would discard the -NTSC/CRT/Bisqwit shader stack). So the demo HUD and paywall are **native Compose/SwiftUI**, not egui; -there is no egui in the shipped mobile UI. - -(Render note: the locked plan ships filters via an **AGSL `RuntimeShader`** post-process on the -Compose path for v1.8.0, with the full wgpu-on-`SurfaceView` WGSL stack as a documented follow-up. -Either way it's presentation-only and determinism-safe.) - ---- - -## 4. Premium-feature mapping = the three persistence features - -`PremiumFeature` gates exactly the three **persistence** features; the free (ad-supported) tier -keeps everything else: - -| Variant | What it gates (locked on the free, ad-supported tier) | -|---|---| -| `SaveStates` | F1/F4 save/load slots + the thumbnail Save-States manager | -| `SaveOnExitResume` | `onPause` writes an `auto` state + auto-resume on relaunch | -| `BatterySaves` | persisting on-cart battery-backed SRAM (and FDS RAM) to disk | - -**Free even on the ad tier (do NOT gate):** full emulation accuracy, video + shaders, audio, all -input, **pause, fast-forward, and in-session rewind** (the 600-frame ring is RAM, not persistence). -The free tier's play time is governed by the 8-min session + rewarded-ad extensions, not by gating -these. (An earlier draft briefly gated `Rewind`/`FastForward` — removed.) - -Save format is unchanged from desktop: **`.rns`**, laid out as `/slot-N.rns`, now under -Android `Context.filesDir` (cross-device-portable, byte-identical). - -RetroAchievements is **deferred** from the Android MVP, so its hardcore-mode disabling of -save-state/rewind/cheats is a later-increment concern, not a v1.8.0 interaction. - ---- - -## 5. The determinism boundary (unchanged, reinforced) - -Monetization state (the play-time gate, ad cadence, the premium flag, the persistence gates, -`now_ms`) must **never** enter `rustynes-core::Bus` or the scheduler — the bit-determinism contract -underpins save-states, the `.rns`/`.rnm` formats, rollback netplay, and TAS, and desktop⇄Android -cross-play depends on the core staying platform-independent. Concretely, per the plan: touch/gamepad -converge on the one `Buttons` mask → `SharedInput` → `EmuCore::latch` at the **same late-latch a -keypress uses** (no new determinism surface, exactly as the wasm touch overlay proved); run-ahead's -speculative frames are already rolled back and never reach save-states/TAS; new mobile -pacing/throttle knobs are **frontend-only**. The free and paid tiers are **byte-identical** for a -given input — the difference is ads + a session clock + the three persistence gates, all host-side. -Tick `add_active_time` from the frame loop only while unpaused; call `start_play()` at launch / -first ROM load. - ---- - -## 6. Unlocking the app for the 15 closed-test testers - -With RevenueCat primary, **all** of runbook §5a applies — use whichever fits: -- **RevenueCat promotional grant** (Method 1) — grant the `premium` entitlement to a tester from - the dashboard or REST API; no purchase, revocable. Simplest way to hand the unlocked build to the - cohort. -- **Google Play License testing** (Method 2) — free *test purchase* of the "Remove Ads / Full - Version" product, exercising the real RevenueCat → entitlement path; add testers to the License - testing list in addition to the closed-test track. -- **`TESTER_UNLOCK` debug override** / `PLAY_BUILD=false` — for local on-device QA (the analogue of - the plan's `debug.force_unlocked`); inert in the release/closed-test build. - -The closed-track upload is still what exercises the real billing flow (Billing can't transact on a -sideloaded build), and this is the **v1.8.8** Play-debut cohort. - ---- - -## 7. Build & toolchain (from the v1.8.0 plan) - -- **NDK r27 LTS**; verify **16 KB page alignment** (a Play requirement for Android 15+ regardless of - NDK). -- Build the `.so` with **`cargo-ndk`** invoked from Gradle via **`cargo-ndk-android-gradle`** → - **AAB** (not `cargo-apk`, which is APK-only / can't publish to Play). Targets `aarch64-linux-android` - (ship) + `x86_64-linux-android` (emulator/CI). -- **`minSdk 26`** (AAudio floor), **`targetSdk 35`** (Play mandate). **`cpal 0.18`** has a native - AAudio backend at API 26, so the audio ring works unchanged. JNI glue is **`jni` 0.21**. -- Billing: **RevenueCat** bundles the Play **Billing Library** (note v8 is mandatory by 2026-08-31, - which current RevenueCat ships); RevenueCat handles acknowledge/restore. AppLovin MAX SDK 13+ for - ad mediation; complete the **certified-CMP/UMP consent flow before MAX init** in EEA/UK/CH. -- Enroll in **Play App Signing**; keep a **guaranteed sideload / F-Droid / GitHub-Releases channel** - (full-featured, no ads) so the project never depends solely on Play. - ---- - -## 8. ROM policy & data safety (already store-compliant) - -ROM import via the **Storage Access Framework** (`ACTION_OPEN_DOCUMENT`, optional -`ACTION_OPEN_DOCUMENT_TREE`) with **persistable URI grants**; bytes go to `Nes::from_rom` (never a -path). **No bundled commercial ROMs, no downloader** — which is exactly why emulators are allowed on -Play. Data-safety = "no data collected" for the MVP. Keep this model on iOS via `UIDocumentPicker`. - ---- - -## 9. Deferred from the Android MVP - -**Netplay, RetroAchievements, and Lua are deferred** to later point releases (UDP compiles but mobile -CGNAT needs the TURN story; rcheevos needs a Compose OAuth UI; mlua cross-compiles but is gated). -Don't design the monetization layer around them. - ---- - -## 10. License - -Dual **MIT OR Apache-2.0** — a commercial freemium Play build with the proprietary RevenueCat/ -AppLovin SDKs is fine (no GPL conflict). The source is public, and per the launch timing the -**sideload / GitHub builds are full-featured with no ads (`PLAY_BUILD=false`)**; only the Play AAB -carries ads + the timer + the entitlement gate. That's consistent and good for goodwill — set -expectations rather than obscuring it. - ---- - -## 11. RustyNES-side change checklist - -- [x] **Monetization decided:** ad-supported (RevenueCat + AppLovin), one-time "Full Version / - Remove Ads" at **$3.99** — overrides the plan's ad-free default. -- [ ] Stand up `rustynes-mobile` (UniFFI control surface) + `rustynes-android` (`jni` 0.21 glue) + - the Gradle module; add `rustynes-monetization` as a mobile-only member beside them. -- [ ] Wire the shells: RevenueCat → `set_premium`; AppLovin MAX → `should_show_interstitial` / - `notify_interstitial_shown` and the rewarded → `grant_rewarded_time` (§2). -- [ ] Gate `SaveStates` / `SaveOnExitResume` / `BatterySaves` / `FastForward` / `Shaders` / `Cheats` (§4); keep in-session rewind free (fast-forward/shaders/cheats are now premium, decided 2026-06-23). -- [ ] Wire the `PLAY_BUILD` flavor (v1.8.2); keep sideload builds full-featured (no ads); flip the - freemium layer on at the **v1.8.8** Play debut. -- [ ] Create the one-time **"Remove Ads / Full Version" product ($3.99)** + the `premium` - entitlement in RevenueCat + a license-tested account + a closed-track upload (§6). -- [ ] Keep all monetization out of `rustynes-core` and the determinism path (§5). -- [ ] Reconcile against `to-dos/plans/v1.8.0-android-plan.md` and `docs/STATUS.md` at merge time. - -*Grounded in the public RustyNES README and `to-dos/plans/v1.8.0-android-plan.md` (architecture, -crate list, the locked monetization + timing decisions, toolchain). Verify exact symbols against the -source at merge time.* diff --git a/docs/originality-and-provenance.md b/docs/originality-and-provenance.md index f51aea30..a5730ce8 100644 --- a/docs/originality-and-provenance.md +++ b/docs/originality-and-provenance.md @@ -116,6 +116,23 @@ re-derived the physical mechanism from die-level evidence, converging with some references and diverging from others on the strength of the hardware model rather than by following any single one of them. +**An honest caveat on the calibration (added v2.2.6).** The framing above understates +one dependency, and a NESdev reviewer (Fiskbit) was right to flag it. Beyond using +TriCNES as a pass/fail oracle for the two AccuracyCoin tests, RustyNES calibrated the +octal-latch *timing itself* against TriCNES's per-dot trace — specifically the +delayed-`CopyV` countdown (`COPY_V_DELAY = 4`), tuned to match TriCNES rather than +derived from an independent hardware measurement. That went beyond black-box oracle +use: it is behavioral calibration to one specific emulator's model. The consequence is +concrete — TriCNES's hybrid-address handling was itself imperfect (it has since been +revised upstream), and RustyNES inherited a matching artifact that mis-renders games +performing mid-render `$2006` writes (e.g. **Rad Racer**'s road/horizon split). This is +disclosed here rather than glossed. The **v2.3.0 "Datum II"** release reworks the +hybrid-address model to be derived from public hardware documentation and validated +against real-game behavior (Rad Racer) — not calibrated to any single emulator — behind +the project's standard default-off-flag / oracle-gated guardrails (see ADR 0030). No +TriCNES code was ever incorporated (it is MIT-licensed regardless); the issue was +behavioral fidelity, and the remedy is to make the behavior documentation-derived. + ### 2.3 The sprite-evaluation FSM and OAM data bus (ADR 0034) RustyNES models the PPU's sprite-evaluation datapath as an explicit per-dot state diff --git a/ios/RustyNES/BuildChannel.swift b/ios/RustyNES/BuildChannel.swift index 5d642055..03230dfd 100644 --- a/ios/RustyNES/BuildChannel.swift +++ b/ios/RustyNES/BuildChannel.swift @@ -1,56 +1,21 @@ // -// BuildChannel.swift (v1.9.8 "Horizon") +// BuildChannel.swift // -// The foss / App-Store distribution seam, the iOS counterpart of the Android -// `foss` / `play` flavor split (ADR 0025) and the iOS distribution decision in -// ADR 0027 §3. A compile-time Active Compilation Condition (`APPSTORE_BUILD`) -// selects the channel: +// The distribution channel tag for the iOS build. RustyNES is permanently +// open-source and income-free (ADR 0035): there is a single free channel with +// every feature unlocked, no ads, no in-app purchase, and no tracking. There is +// no App-Store monetization flavor and no StoreKit / ad-SDK compilation seam. // -// - DEFAULT (no `APPSTORE_BUILD`) = the `foss` channel: AltStore PAL / GitHub / -// TestFlight. Ad-free, tracking-free, "Data Not Collected". This is what every -// build does today. -// - `APPSTORE_BUILD` defined = the `appstore` channel: the future home of the -// StoreKit 2 + RevenueCat "$3.99 / Remove Ads" unlock and the AppLovin MAX ads -// gated behind App Tracking Transparency. -// -// v1.9.8 only LAYS the seam — BOTH channels currently behave identically (fully -// unlocked, no ads, no tracking). The real split is flipped on at v2.1.0 (ADR 0027), -// when `StoreManager` (see `Entitlements.swift`) and the `rustynes-monetization` -// AdPolicy core are wired in. To produce the App-Store flavor, add `APPSTORE_BUILD` -// to `SWIFT_ACTIVE_COMPILATION_CONDITIONS` for that build configuration (see the -// documented note in `ios/project.yml`); the default scheme stays `foss`. +// Kept as a tiny helper so the existing `BuildChannel.isFoss` call sites (e.g. the +// developer debugger surface) keep reading cleanly; every property is constant. // import Foundation enum BuildChannel { - enum Channel { case foss, appStore } - - /// The channel this binary was compiled for. - static let current: Channel = { - #if APPSTORE_BUILD - return .appStore - #else - return .foss - #endif - }() - - static var isAppStore: Bool { current == .appStore } - static var isFoss: Bool { current == .foss } - - /// Whether StoreKit / RevenueCat purchasing is even compiled in for this channel. - /// (Still gated by `StoreManager.storeKitEnabled`, which stays false in v1.9.x.) - static var usesStoreKit: Bool { isAppStore } - - /// Whether the ad SDK is compiled in for this channel. The foss channel is always - /// ad-free; even on the App-Store channel ads stay dormant through v1.9.x. - static var allowsAds: Bool { isAppStore } + /// Always true — the only channel is the free, open-source one. + static var isFoss: Bool { true } /// A short human-readable tag for the About screen / diagnostics. - static var displayName: String { - switch current { - case .foss: return "FOSS" - case .appStore: return "App Store" - } - } + static var displayName: String { "FOSS" } } diff --git a/ios/RustyNES/DebuggerView.swift b/ios/RustyNES/DebuggerView.swift index af3ea599..4b200ebf 100644 --- a/ios/RustyNES/DebuggerView.swift +++ b/ios/RustyNES/DebuggerView.swift @@ -8,9 +8,9 @@ // the emulator paused. Optional `.sym` / `.mlb` / `.nl` symbol files annotate the // disassembly (parsed host-side; see SymbolMap.swift). // -// Gated OFF the App-Store build via `BuildChannel`: this developer surface is -// reachable only on the FOSS / TestFlight channel (ADR 0027 distribution seam). -// The in-game menu entry and this sheet are both conditional on `BuildChannel.isFoss`. +// Reachable through `BuildChannel.isFoss` (RustyNES is a single free, open-source +// channel, so this developer surface is always available). The in-game menu entry +// and this sheet are both conditional on `BuildChannel.isFoss`. // import SwiftUI diff --git a/ios/RustyNES/Entitlements.swift b/ios/RustyNES/Entitlements.swift deleted file mode 100644 index e78a090d..00000000 --- a/ios/RustyNES/Entitlements.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// Entitlements.swift (v1.9.1 "Patch" / v1.9.8 "Horizon") -// -// DORMANT freemium-gate scaffold. Through the ENTIRE v1.9.x TestFlight train the -// app is fully unlocked and free — TestFlight builds carry no purchase and the -// privacy label stays "Data Not Collected". This type is the seam the v2.1.0 -// launch wires to the shared `rustynes-monetization` crate (RevenueCat + -// StoreKit 2 + AppLovin MAX, the ad-supported $3.99 model; see ADR 0025 and -// `to-dos/plans/v2.0.x-mobile-finalization-plan.md`). v1.9.8 "Horizon" lands the -// dormant StoreKit 2 scaffolding (`StoreManager` below) + the foss/App-Store -// channel seam (`BuildChannel`); this stub keeps the gate present-but-inert so the -// v2.1.0 wiring is a drop-in (flip the source from this stub to the -// rustynes-monetization entitlement state) rather than a retrofit — the iOS analog -// of the Android dormant `Billing.kt`. -// -// Determinism note: entitlement state never reaches the emulation core — it -// gates only optional UI surfaces, exactly as `rustynes-monetization` documents. -// - -import Combine -import Foundation -import StoreKit - -/// The app's entitlement state. **Dormant in v1.9.x** — every feature is unlocked. -@MainActor -final class Entitlements: ObservableObject { - /// Whether premium features are unlocked. - /// - /// v1.9.x: always `true` (free + full on TestFlight). v2.1.0: replaced by the - /// `rustynes-monetization` query (purchases / restore / receipt), OR'd with - /// `debugForceUnlock` so dev/QA builds never hit the gate. - @Published private(set) var isUnlocked: Bool = true - - /// A debug-only forced full-unlock. Inert today (already unlocked); it keeps - /// the override meaningful once the v2.1.0 wiring replaces the `true` above - /// with a real entitlement source. - static var debugForceUnlock: Bool { - #if DEBUG - return true - #else - return false - #endif - } - - init() {} - - /// Re-evaluate entitlements. A no-op in v1.9.x; v2.1.0 queries - /// `rustynes-monetization` (which itself respects `debugForceUnlock`). Kept so - /// call sites (`.task { entitlements.refresh() }`) exist from v1.9.1 and don't - /// need to be added during the v2.1.0 wiring. - /// - /// v2.1.0 shape (for reference): on the `foss` channel `isUnlocked` stays - /// permanently `true`; on the `appstore` channel it becomes - /// `StoreManager.shared.purchased || Self.debugForceUnlock`. - func refresh() { - // Dormant: nothing to query yet; `isUnlocked` stays true. - } -} - -/// DORMANT StoreKit 2 scaffold (v1.9.8 "Horizon"). Through v1.9.x this never runs — -/// `storeKitEnabled` is false and `BuildChannel.usesStoreKit` is false on the default -/// `foss` channel, so `products` stays empty and `purchased` stays false WITHOUT -/// gating anything (the app is fully unlocked via `Entitlements.isUnlocked == true`). -/// It is shaped now so the v2.1.0 App-Store wiring (the "$3.99 Full Version / Remove -/// Ads" unlock) is a drop-in: flip `storeKitEnabled` true on the `appstore` channel -/// and OR `purchased` into `Entitlements.isUnlocked`. The product fetch / purchase / -/// restore methods are real StoreKit 2 calls behind the dormant guards, so they are -/// ready to exercise the moment the flag flips. (StoreKit 2 requires iOS 15, which is -/// the deployment floor.) -@MainActor -final class StoreManager: ObservableObject { - /// The single non-consumable "Full Version" product id (matched in App Store - /// Connect at v2.1.0). Mirrors the Android one-time-unlock SKU. - static let fullUnlockProductID = "com.doublegate.rustynes.fullunlock" - - /// Master kill-switch. **false through all of v1.9.x** — every method below - /// returns immediately, so no StoreKit traffic occurs and nothing is gated. - static let storeKitEnabled = false - - @Published private(set) var products: [Product] = [] - @Published private(set) var purchased = false - - /// Fetch the product catalog + reconcile existing entitlements. Dormant in v1.9.x. - func refresh() async { - guard Self.storeKitEnabled, BuildChannel.usesStoreKit else { return } - do { - products = try await Product.products(for: [Self.fullUnlockProductID]) - await reconcileEntitlements() - } catch { - // Dormant scaffold: swallow; the v2.1.0 wiring surfaces this to the user. - } - } - - /// Buy the Full Version unlock. Dormant in v1.9.x. - func purchaseFullUnlock() async { - guard Self.storeKitEnabled, BuildChannel.usesStoreKit, - let product = products.first else { return } - do { - let result = try await product.purchase() - if case .success(let verification) = result, - case .verified(let transaction) = verification { - purchased = true - await transaction.finish() - } - } catch { - // Dormant scaffold. - } - } - - /// Restore prior purchases. Dormant in v1.9.x. - func restore() async { - guard Self.storeKitEnabled, BuildChannel.usesStoreKit else { return } - try? await AppStore.sync() - await reconcileEntitlements() - } - - /// Mark `purchased` from the current StoreKit entitlements. - private func reconcileEntitlements() async { - var unlocked = false - for await result in Transaction.currentEntitlements { - if case .verified(let transaction) = result, - transaction.productID == Self.fullUnlockProductID, - transaction.revocationDate == nil { - unlocked = true - } - } - // Assign after the loop so a revoked/refunded purchase (no verified current - // entitlement) correctly flips `purchased` back to false. - purchased = unlocked - } -} diff --git a/ios/RustyNES/PrivacyInfo.xcprivacy b/ios/RustyNES/PrivacyInfo.xcprivacy index 17a667f5..2ec0b159 100644 --- a/ios/RustyNES/PrivacyInfo.xcprivacy +++ b/ios/RustyNES/PrivacyInfo.xcprivacy @@ -25,9 +25,9 @@ is functionality-only and "Data Not Linked" in the App Store nutrition label. ROMs are never uploaded. - ATT / ads: NONE in any v1.9.x build. App Tracking Transparency + the ad SDK are a - v2.1.0 App-Store-flavor (appstore channel, ADR 0027) addition; NSPrivacyTracking - stays false and the tracking-domains array stays empty here. + ATT / ads: NONE, ever. RustyNES is permanently open-source and income-free + (ADR 0035) — there is no advertising, no in-app purchase, and no tracking in any + build. NSPrivacyTracking stays false and the tracking-domains array stays empty. v2.0.7 "Trim" re-audit (against the v2.0.6 crash-reporting addition): the opt-in crash reporter (CrashReporter.swift) collects NO new data type and adds NO new diff --git a/ios/RustyNES/RustyNESApp.swift b/ios/RustyNES/RustyNESApp.swift index d1866f56..8e3f9f50 100644 --- a/ios/RustyNES/RustyNESApp.swift +++ b/ios/RustyNES/RustyNESApp.swift @@ -11,25 +11,17 @@ import SwiftUI @main struct RustyNESApp: App { @StateObject private var model = AppModel() - // v1.9.1: the dormant freemium gate, injected app-wide so the v2.1.0 - // monetization wiring is a drop-in. Fully unlocked through v1.9.x. - @StateObject private var entitlements = Entitlements() @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { ContentView() .environmentObject(model) - .environmentObject(entitlements) // Open a ROM dragged onto the app / shared from Files (.nes/.zip // are registered in Info.plist; the bridge is iNES/NES 2.0-only). .onOpenURL { url in Task { await model.importAndOpen(url) } } - // `onAppear` runs on the main actor with a synchronous closure, so - // it can call the `@MainActor` `refresh()` directly (no `await` - // hop, clean under strict concurrency). Dormant no-op in v1.9.x. - .onAppear { entitlements.refresh() } } .onChange(of: scenePhase) { phase in model.handleScenePhase(phase == .active) diff --git a/ios/project.yml b/ios/project.yml index 8b66f6f4..936c6af3 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -9,18 +9,10 @@ # scripts/build-ios-xcframework.sh). A static framework is LINKED, not embedded: # `embed: false` (Xcode "Do Not Embed"). It carries no code signature of its own. # -# v1.9.8 "Horizon" — the foss / App-Store distribution seam (ADR 0027 §3, the iOS -# counterpart of the Android `foss`/`play` split). The DEFAULT build is the `foss` -# channel (AltStore PAL / GitHub / TestFlight): ad-free, tracking-free, fully -# unlocked. To produce the future App-Store flavor, add `APPSTORE_BUILD` to that -# build configuration's `SWIFT_ACTIVE_COMPILATION_CONDITIONS`, e.g.: -# -# settings: -# base: -# SWIFT_ACTIVE_COMPILATION_CONDITIONS: $(inherited) APPSTORE_BUILD -# -# `BuildChannel.swift` reads that flag; `StoreManager` (Entitlements.swift) stays -# DORMANT until v2.1.0 regardless of channel, so both paths behave identically today. +# RustyNES is permanently open-source and income-free (ADR 0035): a single free +# build with every feature unlocked, no ads, no in-app purchase, and no tracking. +# There is no App-Store monetization flavor and no StoreKit / ad-SDK compilation +# seam — `BuildChannel.swift` is a constant single-channel tag. # # Localization (v1.9.8): RustyNES/Localizable.xcstrings (the EN+ES String Catalog) is # picked up automatically — it lives under the `RustyNES` source path below, which @@ -106,11 +98,8 @@ targets: # v1.9.8 "Horizon" (store-readiness): # ReplayKit — gameplay screen capture (ScreenRecorder.swift). # GameKit — opt-in Game Center sign-in + access point (GameCenterModel.swift). - # StoreKit — the DORMANT StoreKit 2 scaffold (StoreManager in Entitlements.swift); - # linked now so the v2.1.0 App-Store-flavor wiring is a drop-in. - sdk: ReplayKit.framework - sdk: GameKit.framework - - sdk: StoreKit.framework info: path: RustyNES/Info.plist diff --git a/mkdocs.yml b/mkdocs.yml index 9f624bbf..2b0399dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -157,14 +157,13 @@ nav: - Build & Tooling: build-and-tooling.md # Engineering-only material excluded from the built site entirely: audit logs, -# ADRs, monetization scaffolding, release-note archives, internal readiness -# records, salvage manifests, and the frozen dev/testing/tooling trees. +# ADRs, release-note archives, internal readiness records, salvage manifests, +# and the frozen dev/testing/tooling trees. exclude_docs: | adr/ archive/ audit/ dev/ - monetization/ release-notes/ testing/ tooling/ diff --git a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md index 7d688528..a262f6bc 100644 --- a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md +++ b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md @@ -15,7 +15,8 @@ > **Current state (2026-06-23).** v1.7.0 "Forge" (+ the v1.7.1 patch) and the full > Android train **v1.8.0 … v1.8.8 "Atlas"** have since **shipped**; **v1.8.9** is > now in development (Dependabot consolidation #180, the dormant -> `rustynes-monetization` crate, a held UX fix). Mapper coverage is **168 +> `rustynes-monetization` crate — later removed entirely per ADR 0035, RustyNES +> is permanently open-source and income-free — a held UX fix). Mapper coverage is **168 > families**. Most of the v1.7.0 H reach/polish items that this file lists as > "beta.5 / not yet on `main`" (audio depth, per-game `.json` overrides + DIP > editor + lag counter, web/wasm parity, i18n, spectator netplay, browser-RA HUD, @@ -79,15 +80,18 @@ > v2.1.0 line item. See > [v2.0.x mobile finalization](plans/v2.0.x-mobile-finalization-plan.md). > -> **`foss` / `play` Android flavor split (v2.1.0, ADR 0025).** Split the Android build into +> **`foss` / `play` Android flavor split (ADR 0025).** Split the Android build into > a **`foss`** flavor (default — no Google SDKs, no ads, no tracking; the **F-Droid** + -> sideload artifact) and a **`play`** flavor (all proprietary SDKs — Billing / Cast / Play -> Games / Integrity / update-review **+ AppLovin + RevenueCat** — for Google Play). F-Droid -> requires a Google-/ad-free build, so the flavor is the only way to reach that channel. The -> five existing proprietary subsystems move behind `src/play/` façades (no-op in `src/foss/`), -> with an `installDebug` alias; both flavors verified on-device at v2.0.9. **Done in v1.8.9 -> (dormant):** the clean `rustynes-monetization` crate `.so` + UniFFI bindings are wired into -> the Android build. Target: **v2.0.1–v2.0.4 (lands by v2.1.0)**. +> sideload artifact) and a **`play`** flavor (optional free Google Play services — +> Cast / Play Games / Integrity / update-review — for Google Play). F-Droid +> requires a Google-/ad-free build, so the flavor is the only way to reach that channel. +> Billing and the AppLovin + RevenueCat monetization glue originally planned for the +> `play` flavor were dropped — RustyNES is permanently open-source and income-free +> (ADR 0035), so both flavors ship free with no ads, no tracking, and no paid unlock. +> The `rustynes-monetization` crate built in v1.8.9 has since been removed entirely. +> Status: the flavor split lands with the mobile finalization train; there is **no +> fixed store-launch timeline** — any free store listing is a later, unversioned step +> (ADR 0035). --- diff --git a/to-dos/README.md b/to-dos/README.md index b487c64e..66ab00b2 100644 --- a/to-dos/README.md +++ b/to-dos/README.md @@ -84,8 +84,9 @@ web/wasm parity + i18n + mapper breadth → **168 families** (v1.7.0/v1.7.1); an the **Android app** (v1.8.0 … v1.8.8 "Atlas"). **In development — v1.8.9:** the 13-PR Dependabot consolidation (#180), the -dormant `rustynes-monetization` crate build-out (activates the v2.1.0 freemium -model), and a held UX fix. +dormant `rustynes-monetization` crate build-out (later removed entirely — see +ADR 0035; RustyNES is permanently open-source and income-free), and a held UX +fix. Genuine remaining post-v1.8.8 items (see `ROADMAP.md` for detail): @@ -97,9 +98,10 @@ Genuine remaining post-v1.8.8 items (see `ROADMAP.md` for detail): (v1.8.x GitHub-sideload) + iOS (v1.9.0 TestFlight) interim, then the app-store launches deferred to **after v2.0.0** and shipped jointly at **v2.1.0** — **Google Play + Apple App Store + F-Droid** (Android final v2.0.1–v2.0.4, iOS - final v2.0.5–v2.0.8, both-apps readiness v2.0.9). v2.1.0 also lands the - ad-supported freemium monetization (AppLovin MAX + RevenueCat, a $3.99 - remove-ads unlock) and the `foss`/`play` Android flavor split (ADR 0025). See + final v2.0.5–v2.0.8, both-apps readiness v2.0.9). The joint launch also lands + the `foss`/`play` Android flavor split (ADR 0025) — free Google Play services + vs. pure AOSP, no ads/billing on either flavor; RustyNES is permanently + open-source and income-free (ADR 0035). See [`plans/v2.0.x-mobile-finalization-plan.md`](plans/v2.0.x-mobile-finalization-plan.md). - The externally-blocked **RetroAchievements account-allowlisting** pass (a request to the RA team, not a code change). diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 785c72ea..ddceb90e 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -27,8 +27,12 @@ one-clock/every-cycle-bus-access scheduler collapse, ADR 0002/0029, shipped that closed the "deepen the existing project" run, and the maintenance / distribution / provenance patches **v2.2.1 → v2.2.5** — of which **`v2.2.5` "Colophon" (provenance, licensing, and documentation integrity) is the current -shipped tag**. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid -launch** (activating `rustynes-monetization`) is the future **`v2.3.0`**. +shipped tag**. The freed **`v2.3.0`** slot is repurposed as the accuracy/fidelity +capstone of the **v2.2.6 → v2.3.0** "de-monetization + NESdev-remediation" line (see +below). **RustyNES is permanently open-source and income-free (ADR 0035): all planned +monetization is removed and the native apps are kept as free FOSS apps.** A **free** +mobile-app distribution (GitHub sideload today; optionally a free F-Droid / App Store +listing later) may still happen — with no ads, no tracking, and no paid unlock. Where the detailed sections below carry the inbound engine's own `v1.x`/`v2.x` tags, read them as upstream engine history (its v2.0–v2.8 line), which maps onto the integration stages roughly as: engine v1.0.0 → RustyNES v0.9.0; @@ -50,7 +54,8 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. ## Status -- **Current release:** **RustyNES v2.2.4** (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%)**, nestest 0-diff, by construction. The work is a libretro-completeness audit + metadata correction: the core is confirmed to inherit every v2.2.3 change automatically (the fast-dot-path default, the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema handled transparently by the dynamic `snapshot_core_into` sizing, the `Mapper::mix_audio` i32 widening, the Zapper model, and the `mNNN_` mapper rename), and both buildbot cross-ABIs the GitHub gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — build clean. `rustynes_libretro.info` (the metadata RetroArch's core downloader reads) is corrected: **`disk_control` `false` → `true`** (the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed, but was advertised as absent — the real fix), `display_version` `v1.0.0` → `v2.2.4`, and the mapper count `168` → `172`. Also: the reviewer-tooling standardization onto the shared Antigravity template rides along (`scripts/agy-review.sh` + workflow). Documented libretro follow-up: **core options** (region / overscan / palette / accuracy toggles) remain unexposed (`core_options = "false"` is accurate, not stale) — a deliberate future enhancement, not a v2.2.4 gap. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.2.4]` + `docs/libretro/`. +- **Current release:** **RustyNES v2.2.6 "Almanac"** (2026-08-04) — the 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; zero emulation-core change, AccuracyCoin 141/141 by construction). **`docs/STATUS.md` + `CHANGELOG.md` are the source of truth.** The libretro-cut detail that follows is the historical **v2.2.4** record, retained below. +- **Historical detail — v2.2.4** (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%)**, nestest 0-diff, by construction. The work is a libretro-completeness audit + metadata correction: the core is confirmed to inherit every v2.2.3 change automatically (the fast-dot-path default, the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema handled transparently by the dynamic `snapshot_core_into` sizing, the `Mapper::mix_audio` i32 widening, the Zapper model, and the `mNNN_` mapper rename), and both buildbot cross-ABIs the GitHub gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — build clean. `rustynes_libretro.info` (the metadata RetroArch's core downloader reads) is corrected: **`disk_control` `false` → `true`** (the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed, but was advertised as absent — the real fix), `display_version` `v1.0.0` → `v2.2.4`, and the mapper count `168` → `172`. Also: the reviewer-tooling standardization onto the shared Antigravity template rides along (`scripts/agy-review.sh` + workflow). Documented libretro follow-up: **core options** (region / overscan / palette / accuracy toggles) remain unexposed (`core_options = "false"` is accurate, not stale) — a deliberate future enhancement, not a v2.2.4 gap. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.2.4]` + `docs/libretro/`. - **Release line since v2.1.0:** the v2.1.x **"Fathom"** accuracy line (v2.1.0 → v2.1.10) → **v2.2.0 "Capstone"** (the milestone cut closing the "deepen the existing project" run) → **v2.2.1** (housekeeping) → **v2.2.2 "Conduit"** (build / distribution / CI-integrity) → **v2.2.3 "Datum"** (performance appraisal + the last two Holy Mapperel residuals closed) → **v2.2.4** (this libretro/RetroArch cut). Each is byte-identical NTSC (AccuracyCoin 141/141 throughout). **Full per-release detail is in `CHANGELOG.md` and `docs/STATUS.md` (the single source of truth)** — the entries below (v2.1.0 "Fathom" was the prior anchor here; v2.0.8 → v2.0.1) are the older historical trail, retained rather than duplicated. - **Preceding release:** **RustyNES v2.0.8 "Harbor"** (2026-07-09) — the eighth release of the **v2.0.x mobile-finalization train** and the **iOS release candidate** ("Harborlight"), the final release of the iOS finalization window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.7** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It stages the App Store scaffolding for v2.1.0: version-controlled **App Store Connect listing metadata** (`fastlane/metadata/ios/{en-US,es-ES}/`, mirroring the Android tree, files-only), a **dormant App Store `release` lane** in `fastlane/Fastfile` that stages the build + listing but **does not submit** (`submit_for_review: false`) and is **not** CI-wired (the interim channel stays **TestFlight**), and an **App-Review §4.7 self-audit** (no bundled/downloadable ROMs, ownership notice, searchable library, 4+ rating) in `docs/ios-v2.0.8-readiness.md`. Version bump (workspace `2.0.7 → 2.0.8`; iOS `MARKETING_VERSION → 2.0.8`). **No store submission** (that is v2.1.0); screenshots, real signing, the listing upload, and the App-Review submission are the **maintainer / v2.0.9 / v2.1.0** closeout. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.8]` + `docs/ios-v2.0.8-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. - **Earlier in the train:** **RustyNES v2.0.7 "Harbor"** (2026-07-09) — the seventh release of the **v2.0.x mobile-finalization train** and the **third iOS finalization release** ("Trim"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.6** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It wires the **App Store submission floor** (Apple mandates the **iOS 26 SDK / Xcode 26** for every App Store Connect upload from **2026-04-28**, so the tag-gated iOS CI now selects the newest Xcode 26.x on the runner — a build-SDK pin, non-breaking fallback on older images), **reconciles the deployment target `iOS 15.0 → 17.0`** to match the code's real API floor (`NavigationStack` iOS 16 + `.topBarTrailing` iOS 17, unguarded at 12+ sites — the prior 15.0 was never buildable), and **re-audits `PrivacyInfo.xcprivacy`** against the v2.0.6 crash reporter (no new data type / required-reason API — local-only, backup-excluded, off by default). Version bump (workspace `2.0.6 → 2.0.7`; iOS `MARKETING_VERSION → 2.0.7`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device profiling + the Xcode-26 archive are a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.7]` + `docs/ios-v2.0.7-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. @@ -70,7 +75,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. - **v1.5.0 "Lens"** (2026-06-17) — the insight + scriptability + creator-tooling + polish release, eight additive workstreams: debugger visualization (Input Miniatures overlay, PPU event-viewer heatmap, per-scanline trace viewer, HD-pack per-pixel inspector); Lua dev/TAS API depth; creator/TAS tooling (a TASVideos compatibility pass, NSF waveform scope); frontend pacing & audio-sync perf; a native-UI overhaul + in-app Documentation pane; UX polish (named-palette editor, an "Enhancements" group with sprite-limit-disable/overclock staged-but-inert pending v2.0 per ADR 0002); accessibility (UI scaling, high-contrast + Okabe-Ito themes, keyboard-only nav); mapper breadth **113 → 123 families**; and casual-mode browser RetroAchievements *scaffolding* (ADR 0015, off-by-default `browser-cheevos`). See `CHANGELOG.md` `[1.5.0]`. - **v1.6.0 "Studio"** (2026-06-18) — the studio / TAS-tooling / debugger-depth / accuracy-and-breadth release: the TAStudio piano-roll TAS editor + `.fm2`/`.bk2` movie interop + Lua driving/data; Mesen2-class debugger depth (expression/conditional breakpoints + R/W/X watchpoints + a hex editor + RAM search); off-axis-accuracy verification; mapper breadth → **150 families** + the UNIF (`.unf`) loader; FDS-proper; A/V recording; HD audio; and the shader/filter ecosystem (LMP88959 NTSC/PAL + hqNx/xBRZ + constrained `.slangp`/`.cgp` import). See `CHANGELOG.md` `[1.6.0]`. - **v1.7.0 "Forge"** (2026-06-19) + the **v1.7.1** patch — the writable/programmable-tooling + accuracy + mapper-breadth + reach release (MAXIMAL A–H over five betas + a wave-2 reach pass): F accuracy hardening; G1 reusable-ASIC mappers **150 → 168 families**; A editing-capable tools + inline 6502 assembler; C debugger depth (callstack/step + `.dbg` source maps); B scriptable TAStudio (`tastudio.*`) + full Lua parity; E host IPC/automation behind the off-by-default `script-ipc` feature (ADR 0016); D Zwinder rewind + movie import; G2/G3 expansion-audio; G5 HD-Pack Builder (ADR 0017) + the real-Mesen `` loader fix (ADR 0018); plus the H1–H9 reach wave (browser-RA finish + RA HUD, spectator netplay, per-game `.json` overrides + DIP editor + lag counter (ADR 0019), audio depth (ADR 0020), web/wasm parity (ADRs 0021/0022), an i18n framework (ADR 0023), and the `full` maximal-native-feature build). v1.7.1 added seven bugfix/polish fixes. See `CHANGELOG.md` `[1.7.0]` + `[1.7.1]`. - - **v1.8.0 … v1.8.9 "Atlas"** (2026-06-19 … 2026-06-20) — the **Android platform train** (the first *platform* releases; new crates `rustynes-mobile` UniFFI bridge + `rustynes-android` JNI glue + an `android/` Gradle/Compose app, ADR 0024). v1.8.0 foundation → v1.8.5 power-user (palette/HD-pack/`.zip`/movies) → v1.8.6 (Lua + RA + direct-IP/LAN netplay) → v1.8.7 "Connectivity completion" (CGNAT/TURN room-code netplay + robust hardware controllers P1–P4) → v1.8.8 "Atlas" (AGP9/Gradle9 + Window-Size-Class adaptive + edge-to-edge/Material You; EN/ES i18n; box-art library; Baseline Profiles + R8 full-mode; capture/MP4-clip + PiP/tile/shortcuts/Glance-widget; TV/Leanback + a11y; Play Games cloud-saves/achievements + Play-Integrity + update/review/vitals, all default-off) → **v1.8.9** (13-PR Dependabot consolidation; the dormant `rustynes-monetization` crate wired into the Android build, not yet live — activates at v2.1.0). See `CHANGELOG.md` `[1.8.0]`…`[1.8.9]`. + - **v1.8.0 … v1.8.9 "Atlas"** (2026-06-19 … 2026-06-20) — the **Android platform train** (the first *platform* releases; new crates `rustynes-mobile` UniFFI bridge + `rustynes-android` JNI glue + an `android/` Gradle/Compose app, ADR 0024). v1.8.0 foundation → v1.8.5 power-user (palette/HD-pack/`.zip`/movies) → v1.8.6 (Lua + RA + direct-IP/LAN netplay) → v1.8.7 "Connectivity completion" (CGNAT/TURN room-code netplay + robust hardware controllers P1–P4) → v1.8.8 "Atlas" (AGP9/Gradle9 + Window-Size-Class adaptive + edge-to-edge/Material You; EN/ES i18n; box-art library; Baseline Profiles + R8 full-mode; capture/MP4-clip + PiP/tile/shortcuts/Glance-widget; TV/Leanback + a11y; Play Games cloud-saves/achievements + Play-Integrity + update/review/vitals, all default-off) → **v1.8.9** (13-PR Dependabot consolidation; a then-dormant `rustynes-monetization` crate wired into the Android build — **since removed permanently in v2.2.6 per ADR 0035; it never went live**). See `CHANGELOG.md` `[1.8.0]`…`[1.8.9]`. - **v1.9.0 … v1.9.9 "Workshop"** (2026-06-25 … 2026-06-26) — the **iOS/iPadOS TestFlight train**, mirroring the Android arc release-for-release on the byte-identical core (new crates `rustynes-ios` Metal/CoreAudio shim reusing `rustynes-mobile` verbatim, ADR 0026). v1.9.0 "Sunrise" foundation (SwiftUI shell + xcframework) → v1.9.4 "Lens" (full wgpu→Metal renderer + WGSL shader stack) → v1.9.6 "Link" (Lua + RetroAchievements + LAN netplay) → v1.9.7 "Relay" (CGNAT/TURN room-code netplay + iCloud/CloudKit save-state sync) → v1.9.8 "Horizon" (accessibility + EN/ES i18n + ReplayKit + Game Center + the dormant StoreKit seam, ADR 0027 §4.7 compliance) → **v1.9.9 "Workshop"** (creator/power tools: Cheats, a FOSS-gated read-only debugger, a touch TAStudio piano-roll, foreign movie import, host-side audio-depth DSP — the final pre-Timebase readiness gate). Distributed by TestFlight only; App Store deferred to v2.1.0 alongside Google Play. See `CHANGELOG.md` `[1.9.0]`…`[1.9.9]`. - **v1.10.0 "Arcade"** (2026-07-01) — the native **Libretro core** (`crates/rustynes-libretro`, RetroArch integration) + the egui 0.34.3 → 0.35.0 dependency-tier refresh. See `CHANGELOG.md` `[1.10.0]`. - **In development — the v2.0.0 tag itself.** All development work for v2.0.0 "Timebase" is merged to `main` (see the bullet above); the only remaining step is the release ceremony (pre-release gate checklist, tag, `release-auto.yml` binary publish). @@ -89,7 +94,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. - **Engine-lineage phase:** Phase 8 — **engine v1.2.0 (2026-05-24).** DMC DMA scheduler refactor landed under default-off cargo feature `dmc-get-put-scheduler` introducing Mesen2's canonical get/put cycle alternation model alongside the v1.1.0 phase-agnostic scheduler via the parallel-implementation pattern (ADR 0007). AccuracyCoin DMA cluster under flag-on: **6/10 match baseline** (closing 4 → 0 deferred to v1.2.x patches or v2.0 master-clock absorption). Default build bit-identical to v1.1.0. - **Engine-lineage — earlier work:** **engine v1.1.0 (2026-05-25)** — VRC7 OPLL FM audio via clean-room pure-Rust port of `emu2413 v1.5.9` (MIT); ADR 0006 supersedes ADR 0004; *Lagrange Point* plays with audio. (engine v1.1.0 was an engine v2.0.0-release-plan milestone slotted between Phase 6 and Phase 8, **not** the ROADMAP's Phase 7 — see the numbering note below.) Phase 6 — **engine v1.0.0 (2026-05-23)**: AccuracyCoin gate CLEARED at 90.65% (126/139); T-60-001 C1 IRQ-timing residuals (3 `cpu_interrupts_v2` sub-ROMs + `mmc3_test_2/4` #3) deferred to the master-clock-precise scheduling refactor (Session-29 empirically falsified Option A global PPU-position shift; 17 documented rollbacks). [That engine-lineage master-clock work subsequently landed in the RustyNES v1.0.0 core, taking AccuracyCoin to 100%.] - **Phase-numbering note:** the shipped releases v1.1.0 → v1.4.0 were sequenced from the v2.0.0 release plan and back-labelled in the detailed sections as v1.1.0 (VRC7) → Phase 8 (v1.2.0 DMC) → Phase 9 (v1.3.0 wasm) → Phase 10 (v1.4.0 TAS). **Phase 7 — Nesdev Accuracy Hardening (below) was authored but never executed**; it is now being executed as **v1.5.0**. See `docs/audit/phase-7-assessment-2026-05-24.md` for the full intent-vs-accomplished-vs-completable disposition. -- **Current state:** **RustyNES v2.2.4 "Cartridge" is the latest tagged release** (see the Status section at the top for the current-release detail and the v2.1.0 → v2.2.4 line). **v2.0.0 "Timebase" shipped 2026-07-03** — the paragraph below describing it as "code-complete, tag pending" is retained as a historical snapshot of that release's landing, not a current-state claim. Every accuracy, compatibility, platform, netplay, RetroAchievements, FDS, Vs/PC10, and performance milestone in the engine-lineage history above is folded into the v1.0.0 core; the v1.1.0 → v1.7.x feature releases then layered (in order) the Lua scripting engine + visual filters/peripherals/devtools/NSF, the library/compatibility/reach pass, the toolchain modernization + Memory-Compare + Vs.-DualSystem detection, the accuracy-and-finish pass, the insight/scriptability/creator-tooling/polish pass, the studio/TAS-tooling/debugger-depth pass, and the writable/programmable-tooling "Forge" pass; the v1.8.x train ported the whole core to Android, the v1.9.x train ported it to iOS/iPadOS, and v1.10.0 added the native Libretro core. Mapper coverage rose **51 → 172 families** across these releases, all additive / off-by-default, with AccuracyCoin holding **100% (139/139)** the entire time. v2.0.0 then landed the one-clock/every-cycle timebase promote + full Vs. `DualSystem` support + the save-state/movie format break — the first genuinely BREAKING release since v1.0.0, by design (ADR 0028/0029). The engine-lineage version markers (v0.9.x → v2.x) in the bullets above and the phase bodies are upstream history, not RustyNES releases. +- **Current state:** **RustyNES v2.2.6 "Almanac" is the latest release** (see the Status section at the top; the v2.2.4 "Cartridge" libretro-cut detail there is historical). `docs/STATUS.md` + `CHANGELOG.md` carry the authoritative v2.1.0 → v2.2.6 line. **v2.0.0 "Timebase" shipped 2026-07-03** — the paragraph below describing it as "code-complete, tag pending" is retained as a historical snapshot of that release's landing, not a current-state claim. Every accuracy, compatibility, platform, netplay, RetroAchievements, FDS, Vs/PC10, and performance milestone in the engine-lineage history above is folded into the v1.0.0 core; the v1.1.0 → v1.7.x feature releases then layered (in order) the Lua scripting engine + visual filters/peripherals/devtools/NSF, the library/compatibility/reach pass, the toolchain modernization + Memory-Compare + Vs.-DualSystem detection, the accuracy-and-finish pass, the insight/scriptability/creator-tooling/polish pass, the studio/TAS-tooling/debugger-depth pass, and the writable/programmable-tooling "Forge" pass; the v1.8.x train ported the whole core to Android, the v1.9.x train ported it to iOS/iPadOS, and v1.10.0 added the native Libretro core. Mapper coverage rose **51 → 172 families** across these releases, all additive / off-by-default, with AccuracyCoin holding **100% (139/139)** the entire time. v2.0.0 then landed the one-clock/every-cycle timebase promote + full Vs. `DualSystem` support + the save-state/movie format break — the first genuinely BREAKING release since v1.0.0, by design (ADR 0028/0029). The engine-lineage version markers (v0.9.x → v2.x) in the bullets above and the phase bodies are upstream history, not RustyNES releases. **v2.0.0 "Timebase" — historical landing snapshot (shipped 2026-07-03; this section was written as it landed). What shipped (2026-07-01 → 2026-07-03):** @@ -106,9 +111,9 @@ The forward architectural milestone this section used to describe as a distant, **Beyond v2.0.0 — the mobile finalization train (maintainer decision, 2026-06-23; unchanged by v2.0.0's completion, just now unblocked):** -- **The Android (v1.8.x) and iOS (v1.9.x) apps ship together, after v2.0.0 — the v2.0.1 → v2.1.0 finalization train.** Both apps were deliberately held back from their app stores until the v2.0.0 "Timebase" core landed, so they can finalize and launch **together**: **v2.0.1–v2.0.4** = final Android additions/modifications/enhancements/fixes re-ported onto the v2.0.0 core; **v2.0.5–v2.0.8** = the same iOS finalization; **v2.0.9** = true correctness checks + ready-for-release verification for *both* apps; **v2.1.0** = the **joint mobile store launch** — Google Play + Apple App Store + AltStore PAL + F-Droid together. Until then the apps continue as **GitHub-sideload** (Android) and **TestFlight** (iOS v1.9.0–v1.9.9, already complete) only. Full plan: [`plans/v2.0.x-mobile-finalization-plan.md`](plans/v2.0.x-mobile-finalization-plan.md). - - **Mobile monetization (v2.1.0).** An ad-supported freemium model — **AppLovin MAX** + **RevenueCat**, a **$3.99** one-time "Full Version / Remove Ads" unlock, +11 min × 2 rewarded ads → a 30-minute session, and six premium features. The dormant `rustynes-monetization` crate (wired into the Android build since v1.8.9) activates here. - - **The `foss` / `play` Android flavor split (ADR 0025).** A **`foss`** flavor (default — no Google SDKs, no ads, no tracking; the F-Droid + sideload artifact) and a **`play`** flavor (all proprietary SDKs, for Google Play). The five proprietary subsystems move behind `src/play/` façades (no-op in `src/foss/`); both flavors are verified on-device at v2.0.9. +- **The Android (v1.8.x) and iOS (v1.9.x) apps ship together, after v2.0.0 — the v2.0.1 → v2.1.0 finalization train.** Both apps were deliberately held back from their app stores until the v2.0.0 "Timebase" core landed, so they can finalize and launch **together**: **v2.0.1–v2.0.4** = final Android additions/modifications/enhancements/fixes re-ported onto the v2.0.0 core; **v2.0.5–v2.0.8** = the same iOS finalization; **v2.0.9** = true correctness checks + ready-for-release verification for *both* apps. Until then the apps continue as **GitHub-sideload** (Android) and **TestFlight** (iOS v1.9.0–v1.9.9, already complete) only. Full plan: [`plans/v2.0.x-mobile-finalization-plan.md`](plans/v2.0.x-mobile-finalization-plan.md). + - **No monetization (ADR 0035, v2.2.6).** The previously-planned ad-supported freemium model (AppLovin MAX + RevenueCat, a one-time "Remove Ads" unlock, rewarded-ad session extensions, premium features) is **removed permanently** — RustyNES is open-source and income-free forever. The `rustynes-monetization` crate is deleted. The native apps remain **free FOSS apps** (no ads, no tracking, every feature unlocked). Any future store listing is a **free** app. + - **The `foss` / `play` Android flavor split (ADR 0025, amended by 0035).** A **`foss`** flavor (default — no Google SDKs; the F-Droid + sideload artifact) and a **`play`** flavor (the *free* Google-Play services — Play Games achievements, Cast, Integrity, in-app update, cloud save; no ads, no billing). The service subsystems live behind `src/play/` façades (no-op in `src/foss/`). - **Beyond v2.1.0 (separate initiatives, no fixed version yet).** - **The R1/R2 MMC3 IRQ-timing axis** — the one open technical gap from v2.0.0 (see above). Next credible avenue per the 2026-07-02 campaign: M2-edge-precise (not CPU-cycle-integer) `gap >= 3` low-time accounting on the falling edge, an axis distinct from everything tried so far — genuinely untested, flagged for a future dedicated session rather than squeezed into any near-term release. - **Vs. Tennis and Vs. Mahjong DualSystem boot** — needs the missing sub-CPU program dumps (not available locally as of v2.0.0; Balloon Fight and Wrecking Crew's dumps were sourced from a legitimately-owned MAME romset). diff --git a/to-dos/plans/v1.8.0-android-plan.md b/to-dos/plans/v1.8.0-android-plan.md index 9e569fda..acb6e214 100644 --- a/to-dos/plans/v1.8.0-android-plan.md +++ b/to-dos/plans/v1.8.0-android-plan.md @@ -33,6 +33,12 @@ > by [`v2.0.x-mobile-finalization-plan.md`](v2.0.x-mobile-finalization-plan.md)). The > implemented `LicenseManager` ($2.99) remains the current sideload behavior. The block below > is the prior locked decision, kept for context. +> +> **(Monetization removed per ADR 0035, v2.2.6.)** This entire monetization plan — the +> `rustynes-monetization` crate, the ad-supported / freemium models described above and +> below, and the `LicenseManager` paywall — was never shipped to a store and has since +> been removed entirely. RustyNES is permanently open-source and income-free. This +> section is retained as a historical record of the original plan. > **Monetization LOCKED by the maintainer (2026-06-19):** ship on the **Google Play Store** > as a **free download with a one-time in-app "Full Unlock" purchase priced at $2.99 USD** diff --git a/to-dos/plans/v1.9.x-ios-train-plan.md b/to-dos/plans/v1.9.x-ios-train-plan.md index c233ca97..d6f05206 100644 --- a/to-dos/plans/v1.9.x-ios-train-plan.md +++ b/to-dos/plans/v1.9.x-ios-train-plan.md @@ -11,6 +11,13 @@ > [`v2.0.x-mobile-finalization-plan.md`](v2.0.x-mobile-finalization-plan.md) for > the post-v2.0.0 phasing. +> **(Monetization removed per ADR 0035, v2.2.6.)** The monetization / freemium +> references in this document (the `rustynes-monetization` crate, the flavor +> scaffolding, and the v2.1.0 activation plan) describe work that was never +> shipped to a store and has since been removed entirely. RustyNES is +> permanently open-source and income-free. Retained below as a historical +> record of the original plan. + This document **supersedes the single-release scope** of [`v1.9.0-ios-plan.md`](v1.9.0-ios-plan.md) (that plan scoped only the v1.9.0 *foundation*; it remains the authoritative engineering spec for the v1.9.0 slice diff --git a/to-dos/plans/v2.0.x-mobile-finalization-plan.md b/to-dos/plans/v2.0.x-mobile-finalization-plan.md index 1d62ffe8..e6e643fc 100644 --- a/to-dos/plans/v2.0.x-mobile-finalization-plan.md +++ b/to-dos/plans/v2.0.x-mobile-finalization-plan.md @@ -93,14 +93,24 @@ suggested phasing; the maintainer may re-slice within each four-release window. `2.0.4`). The core is unchanged / byte-identical to v2.0.3 (AccuracyCoin 141/141). **Maintainer / on-device (deferred to the RC upload + v2.0.9):** the actual Play Console listing upload, signing with the real keystore, the internal / closed testing track, - the crash-free-rate + ANR gate on real hardware, live monetization runtime - verification, the deferred per-feature `feature_enabled` gate migration (keep the - existing `LicenseManager` freemium gates as-is — a v2.0.9 product decision), on-device + the crash-free-rate + ANR gate on real hardware, on-device Baseline Profile regeneration, and closing every open item from the batched on-device - verification pass. + verification pass. (The monetization runtime verification and `feature_enabled` gate + migration originally planned here were dropped — see the note below: ADR 0035 made + RustyNES permanently open-source and income-free.) ### Android — the `foss` / `play` flavor split (lands across v2.0.1–v2.0.4; ADR 0025) +> **Superseded (v2.2.6, ADR 0035):** the monetization glue described in items 2–5 +> below (AppLovin MAX, RevenueCat, the freemium `feature_enabled` gates, the +> `rustynes-monetization` crate and its `shells/`) was never shipped to a store and +> has since been removed entirely. RustyNES is permanently open-source and +> income-free. The `foss` / `play` flavor split itself is **kept** — it still +> distinguishes the pure-AOSP build from a build that adds optional free Google +> Play services (achievements, Cast, Integrity, in-app update, cloud save) — but +> carries no ads, billing, or paid unlock on either flavor. The text below is +> retained as a historical record of the original plan. + A cross-cutting Android-finalization deliverable: split the build into a **`foss`** (default) flavor and a **`play`** flavor so RustyNES can ship to **F-Droid + GitHub sideload** (clean, Google-free, ad-free) *and* **Google Play** (the full proprietary +