Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ android/app/src/main/jniLibs/
# never-commit-generated-bindings convention.
ios/Frameworks/
ios/RustySNES/Sources/Generated-RustysnesMobile.swift
ios/RustySNES/Sources/Generated-RustysnesMonetization.swift
ios/RustySNES.xcodeproj/
ios/build/
*.ipa
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`rustysnes-monetization`** (Mobile Phase 5): a new, standalone UniFFI crate providing a
dormant entitlement/ad-pacing policy scaffold — `check_entitlement`, `default_ad_pacing_policy`,
`should_show_ad`. **Never a dependency of the deterministic core** (no `rustysnes-core`/`-cpu`/
`-ppu`/`-apu`/`-cart` dependency in either direction) and, unlike RustyNES's own already-shipped
module, every concrete pricing/pacing number here is an explicit placeholder default, not a
committed figure — the real store-launch decision stays with `docs/mobile-readiness.md`'s
standing "Mobile Phase 6" gate. Pure functions only, host-injected `now_unix_secs` timestamps
(matching `docs/adr/0004`'s determinism-discipline convention), 5 unit tests covering the
ad-pacing session/interval/clock-rollback logic.
- **Wired into both mobile shells as an inert dependency**: compiled in, called once at startup,
logged only, no real store SDK calls, no paywall/UI shown. **Verified for real on Android**:
rebuilt via a real Gradle build (native `.so` cross-compiled for both ABIs via `cargo ndk`, a
second, separate `uniffiBindgen`-style task generating this crate's own Kotlin bindings
alongside `rustysnes-mobile`'s existing ones), installed on the real AVD, launched, and confirmed
via `logcat`: `monetization scaffold (dormant): unlocked=true minIntervalSecs=300
sessionsBeforeFirstAd=3`, with the app remaining alive with no crash afterward. **iOS**:
`scripts/build-ios-xcframework.sh` gained a third crate to build/package, merged with
`rustysnes-mobile` into one combined `RustysnesFFI.xcframework` (a real macOS CI run caught two
separate per-crate xcframeworks colliding: `xcodebuild` copies every "library"+`-headers`
xcframework's headers into one directory shared across the whole target, and two xcframeworks
each contributing a same-named `module.modulemap` there is a genuine "Multiple commands
produce" build failure — fixed by `libtool -static`-merging both crates' `.a`s and combining
their modulemaps into one umbrella module); `ios/project.yml`'s dependency list updated to
match. The Rust side's `staticlib`/`rlib` outputs cross-compile for real in this development
environment (matching `rustysnes-ios`'s own precedent), but the `cdylib` output the
bindgen step needs only links with a real Apple toolchain, so the full pipeline is
compile-verified via `ios.yml`'s real macOS CI build only, matching this platform's standing
"scaffolded-only" disposition since `v1.16.0`.

## [1.17.0] "Parity" - 2026-07-12

Thirteenth release of the RustyNES-parity roadmap: Mobile Phase 4, hardening.
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/rustysnes-ios",
"crates/rustysnes-gfx-shaders",
"crates/rustysnes-mobile",
"crates/rustysnes-monetization",
"crates/rustysnes-netplay",
"crates/rustysnes-cheevos",
"crates/rustysnes-script",
Expand Down
27 changes: 22 additions & 5 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ android {
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.17.0"
versionName = "1.18.0"
}

buildTypes {
Expand Down Expand Up @@ -63,14 +63,14 @@ val cargoAbis = mapOf(
tasks.register<Exec>("cargoNdkBuild") {
val ndkHome = System.getenv("ANDROID_NDK_HOME")
?: throw GradleException(
"ANDROID_NDK_HOME must be set to build the native rustysnes-mobile/rustysnes-android libraries"
"ANDROID_NDK_HOME must be set to build the native rustysnes-mobile/rustysnes-android/rustysnes-monetization libraries"
)
environment("ANDROID_NDK_HOME", ndkHome)
workingDir = rootProject.projectDir.parentFile
val targetArgs = cargoAbis.keys.flatMap { listOf("-t", it) }
commandLine(
listOf("cargo", "ndk") + targetArgs +
listOf("build", "-p", "rustysnes-mobile", "-p", "rustysnes-android")
listOf("build", "-p", "rustysnes-mobile", "-p", "rustysnes-android", "-p", "rustysnes-monetization")
)
}

Expand All @@ -81,7 +81,7 @@ val copyCargoLibTasks = cargoAbis.map { (abi, triple) ->
tasks.register<Copy>("copyCargoLibs${abi.replace("-", "")}") {
dependsOn("cargoNdkBuild")
from(rootProject.projectDir.parentFile.resolve("target/$triple/debug")) {
include("librustysnes_mobile.so", "librustysnes_android.so")
include("librustysnes_mobile.so", "librustysnes_android.so", "librustysnes_monetization.so")
}
into(project.projectDir.resolve("src/main/jniLibs/$abi"))
}
Expand All @@ -106,10 +106,27 @@ tasks.register<Exec>("uniffiBindgen") {
)
}

// Same shape as `uniffiBindgen` above, for `rustysnes-monetization`'s own separate UniFFI
// library -- a distinct crate/`.so`/namespace, so it needs its own bindgen invocation and output
// directory (`v1.18.0 "Dormant"`).
tasks.register<Exec>("uniffiBindgenMonetization") {
dependsOn("cargoNdkBuild")
workingDir = rootProject.projectDir.parentFile
val soPath = rootProject.projectDir.parentFile
.resolve("target/x86_64-linux-android/debug/librustysnes_monetization.so")
val outDir = project.projectDir.resolve("build/generated/uniffi-monetization")
commandLine(
"cargo", "run", "-p", "rustysnes-monetization", "--features", "bindgen", "--bin", "uniffi-bindgen",
"--", "generate", "--library", soPath.absolutePath, "--language", "kotlin",
"--out-dir", outDir.absolutePath, "--no-format",
)
}

android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi/uniffi")
android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi-monetization/uniffi")

tasks.named("preBuild") {
dependsOn("copyCargoLibs", "uniffiBindgen")
dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization")
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import uniffi.rustysnes_mobile.MobileCore
import uniffi.rustysnes_mobile.MobileRegion
import uniffi.rustysnes_monetization.checkEntitlement
import uniffi.rustysnes_monetization.defaultAdPacingPolicy

/**
* `v1.15.0 "Sideload"` -- the minimal, real Android alpha MVP: a [SurfaceView] rendered via
Expand Down Expand Up @@ -74,6 +76,7 @@ class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
logMonetizationScaffold()
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Expand All @@ -93,6 +96,21 @@ class MainActivity : ComponentActivity() {
}
}

// `v1.18.0 "Dormant"` -- an inert call into `rustysnes-monetization`: compiled in and invoked
// once at startup, logged only, never gating any UI or behavior. No real store SDK is wired
// up; both functions are dormant placeholders (see that crate's own module doc) pending the
// `docs/mobile-readiness.md` "Mobile Phase 6" store-launch decision.
private fun logMonetizationScaffold() {
val entitlement = checkEntitlement((System.currentTimeMillis() / 1000).toULong())
val pacing = defaultAdPacingPolicy()
android.util.Log.d(
"RustySNES",
"monetization scaffold (dormant): unlocked=${entitlement.unlocked} " +
"minIntervalSecs=${pacing.minIntervalSecs} " +
"sessionsBeforeFirstAd=${pacing.sessionsBeforeFirstAd}",
)
}

// Single save-state slot, persisted to app-private internal storage (`filesDir`, no
// permissions needed) -- multi-slot UI is `v1.17.1+` polish, matching the "minimal real MVP"
// scope this mobile track has followed since `v1.15.0`. `AtomicFile`, not a plain `File`
Expand Down
34 changes: 34 additions & 0 deletions crates/rustysnes-monetization/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[package]
name = "rustysnes-monetization"
description = "RustySNES: dormant entitlement/ad-pacing policy scaffold (mobile-only, never a dependency of the deterministic core)"
version = "1.17.0"
edition.workspace = true
Comment thread
doublegate marked this conversation as resolved.
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
keywords = ["snes", "emulator", "mobile", "uniffi"]
categories = ["emulators", "games"]

[lib]
# Same shape as `rustysnes-mobile`'s identical rationale: `cdylib` for Android's JNI loader and
# iOS's `.xcframework` packaging, `staticlib` for iOS static linking, `rlib` so `cargo test` on
# the host works without any mobile toolchain. Unlike `rustysnes-android`/`rustysnes-ios`, this
# crate has zero platform-specific dependencies (no wgpu/JNI/UIKit) -- every target here,
# including the host, builds and tests for real in this environment.
crate-type = ["cdylib", "staticlib", "rlib"]

[features]
# Same opt-in shape as `rustysnes-mobile`'s identical `bindgen` feature -- see that crate's
# Cargo.toml comment for the full rationale (keeps `uniffi/cli` off every real mobile build).
bindgen = ["uniffi/cli"]

[[bin]]
name = "uniffi-bindgen"
required-features = ["bindgen"]

[lints]
workspace = true

[dependencies]
uniffi = "0.29"
9 changes: 9 additions & 0 deletions crates/rustysnes-monetization/src/bin/uniffi-bindgen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Standalone binding-generator binary — `cargo run -p rustysnes-monetization --features bindgen
//! --bin uniffi-bindgen -- generate --library <path-to-cdylib> --language kotlin --out-dir <dir>`
//! produces the Kotlin (or Swift) bindings for [`rustysnes_monetization`]'s `#[uniffi::export]`
//! surface. Matches `rustysnes-mobile`'s identical binary exactly — see that crate's own doc
//! comment for the full rationale.

fn main() {
uniffi::uniffi_bindgen_main();
}
178 changes: 178 additions & 0 deletions crates/rustysnes-monetization/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Dormant entitlement/ad-pacing policy scaffold (`v1.18.0 "Dormant"`, Mobile Phase 5) — the
//! monetization crate RustyNES's own mobile track already ships as a dormant module, ported here
//! as a policy-*shape* template only. **Never a dependency of the deterministic core** —
//! `rustysnes-core`/`rustysnes-cpu`/`rustysnes-ppu`/`rustysnes-apu`/`rustysnes-cart` never depend
//! on this crate, and this crate never depends on them; it has no knowledge of `EmuCore` at all.
//! It is wired into both mobile shells (`android/`, `ios/`) as an inert dependency only: compiled
//! in and called once at startup (logged, not gating anything), no real RevenueCat/AppLovin (or
//! equivalent) SDK calls, no purchase/paywall UI shown.
//!
//! # Why "dormant"
//!
//! Unlike RustyNES's own module (which ships a committed pricing figure), every concrete number
//! here is an explicit placeholder default — [`AdPacingPolicy`]'s field values are a template
//! for a *later* maintainer decision, not a real pricing/pacing commitment.
//! `docs/mobile-readiness.md`'s standing "Mobile Phase 6" store-launch gate is where that decision
//! (and actually wiring a real store SDK) would happen — not here, not automatically.
//!
//! # Determinism discipline (same boundary as the deterministic core, extended here on principle)
//!
//! Every function in this crate is pure: no wall-clock reads, no OS RNG, no hidden state. Time
//! enters only as an explicit `now_unix_secs: u64` parameter the caller supplies (matching
//! `docs/adr/0004`'s "host-injected timestamps" convention already used elsewhere in this
//! project) — this crate never feeds anything back into `EmuCore`, so nothing here is subject to
//! the core's own byte-identical-replay contract, but keeping the same discipline anyway means
//! every function here is trivially unit-testable without mocking a clock.

uniffi::setup_scaffolding!();

/// Whether the user currently has the paid unlock.
#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Entitlement {
/// `true` if paid features should be unlocked.
pub unlocked: bool,
}

/// Placeholder ad-pacing knobs — see the module doc for why every field here is an explicit
/// placeholder, not a committed figure.
#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdPacingPolicy {
/// Minimum seconds between two ad presentations.
pub min_interval_secs: u64,
/// How many play sessions must complete before the very first ad is eligible at all.
pub sessions_before_first_ad: u32,
}

impl AdPacingPolicy {
/// Placeholder values only -- not a committed pacing figure, see the module doc. The single
/// source of truth both [`Default::default`] and [`default_ad_pacing_policy`] return, so the
/// two can never drift apart.
const PLACEHOLDER_DEFAULT: Self = Self {
min_interval_secs: 300,
sessions_before_first_ad: 3,
};
}

impl Default for AdPacingPolicy {
fn default() -> Self {
Self::PLACEHOLDER_DEFAULT
}
}

/// The default, dormant ad-pacing policy.
///
/// Entitlement checks always report unlocked (no paywall active), and ad pacing uses
/// [`AdPacingPolicy::default`]'s placeholder figures. A mobile shell calling this and logging the
/// result (not gating any UI on it) is exactly the "inert dependency" integration this rung wires
/// up — see `android/`/`ios/`'s own call sites.
#[uniffi::export]
#[must_use]
pub const fn default_ad_pacing_policy() -> AdPacingPolicy {
AdPacingPolicy::PLACEHOLDER_DEFAULT
}

/// Check entitlement for `now_unix_secs`.
///
/// Dormant: always reports unlocked (no active paywall) regardless of the timestamp — a real
/// store-backed check (RevenueCat/StoreKit/Play Billing) is a `docs/mobile-readiness.md`
/// "Mobile Phase 6" store-launch decision, not implemented here. `now_unix_secs` is accepted (and
/// ignored, via `let _ = ...` rather than an underscore-prefixed parameter name -- `#[uniffi::
/// export]`'s generated glue references the parameter by name, which trips
/// `clippy::used_underscore_binding` on a `_`-prefixed one) rather than dropped from the
/// signature, so the real check this eventually becomes doesn't need a breaking signature change
/// across the FFI boundary.
#[uniffi::export]
#[must_use]
pub const fn check_entitlement(now_unix_secs: u64) -> Entitlement {
let _ = now_unix_secs;
Entitlement { unlocked: true }
}

/// Pure ad-pacing decision: should an ad be shown right now?
///
/// - `now_unix_secs`: the current time, host-injected (see the module doc).
/// - `last_ad_unix_secs`: when the last ad was shown, or `None` if none has ever been shown this
/// install.
/// - `completed_sessions`: how many play sessions have completed so far.
/// - `policy`: the pacing knobs to evaluate against (typically [`default_ad_pacing_policy`]'s
/// result, but passed explicitly rather than read from hidden state, keeping this function pure
/// and directly unit-testable).
#[uniffi::export]
#[must_use]
pub fn should_show_ad(
now_unix_secs: u64,
last_ad_unix_secs: Option<u64>,
completed_sessions: u32,
policy: AdPacingPolicy,
) -> bool {
if completed_sessions < policy.sessions_before_first_ad {
return false;
}
last_ad_unix_secs
.is_none_or(|last| now_unix_secs.saturating_sub(last) >= policy.min_interval_secs)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn entitlement_is_always_unlocked_while_dormant() {
assert!(check_entitlement(0).unlocked);
assert!(check_entitlement(u64::MAX).unlocked);
}

#[test]
fn ad_pacing_withholds_the_first_ad_until_enough_sessions_completed() {
let policy = AdPacingPolicy::default();
assert!(!should_show_ad(1_000_000, None, 0, policy));
assert!(!should_show_ad(
1_000_000,
None,
policy.sessions_before_first_ad - 1,
policy
));
}

#[test]
fn ad_pacing_allows_the_first_ad_once_the_session_threshold_is_met() {
let policy = AdPacingPolicy::default();
assert!(should_show_ad(
1_000_000,
None,
policy.sessions_before_first_ad,
policy
));
}

#[test]
fn ad_pacing_enforces_the_minimum_interval_between_ads() {
let policy = AdPacingPolicy::default();
let last_ad = 1_000_000;
assert!(!should_show_ad(
last_ad + policy.min_interval_secs - 1,
Some(last_ad),
policy.sessions_before_first_ad,
policy
));
assert!(should_show_ad(
last_ad + policy.min_interval_secs,
Some(last_ad),
policy.sessions_before_first_ad,
policy
));
}

#[test]
fn ad_pacing_saturates_instead_of_panicking_on_a_time_regression() {
// `now < last_ad_unix_secs` (a clock rollback) must not panic via unsigned subtraction
// underflow -- `saturating_sub` clamps to 0, correctly withholding the ad.
let policy = AdPacingPolicy::default();
assert!(!should_show_ad(
0,
Some(1_000_000),
policy.sessions_before_first_ad,
policy
));
}
}
Loading
Loading