From 1b6a69eac33624ff42ac0a125786c32cd6480040 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 13:11:07 -0400 Subject: [PATCH 1/4] feat(mobile): App Store 4.7 self-audit, release build path, UniFFI smoke test The v1.30.0 rung. An audit first found that four entries in docs/mobile-readiness.md's "not yet verified" list had gone stale -- android.yml, the ./gradlew wrapper, the iOS simulator RUN, and much of the CI the plan asked for all exist. They are now marked DONE in place rather than deleted: a readiness document that silently drops items cannot be audited backwards. Three real gaps closed. The App Store 4.7 self-audit (docs/app-store-4-7-self-audit.md) is the one store-facing item that is not maintainer-blocked, and it had never been done. It passes on all five criteria, and the strongest evidence is capability rather than intent: Android declares NO permissions at all, not even INTERNET, and iOS has no networking code, so neither shell CAN obtain game software. Every user-visible string in both shells was enumerated -- the complete set is RustySNES, Open ROM, Save State, Load State. Two re-audit triggers are recorded: the peripheral UI when it lands, since Super Scope / Mouse / Multitap names are a fresh trademark decision the audit does not pre-approve, and rustysnes-monetization if it is ever activated. An unsigned assembleRelease path with its own 16 KB gate on the release APK. Signing material is the maintainer's to provision, so a signed release build stays out of reach; an unsigned one still runs R8, resource shrinking and the release manifest merge, which is where release-only breakage lives. An instrumented UniFFI smoke test, in its own CI job. assembleDebug already proves the bindings COMPILE, because MainActivity calls MobileCore directly. What no build can prove is that System.loadLibrary finds the .so for the device's ABI, that JNA's mapping matches its symbols, and that a call marshals across and returns -- this project has already shipped one native Android crash a build could not have caught. Separate job, because an emulator is the flakiest thing in that workflow and a flaky step inside `build` would put the 16 KB gates behind an AVD boot. Mobile Phase 6 stays NOT GREENLIT. Passing the audit removes a prerequisite from that gate's checklist; it does not move the gate. Distribution signing, TestFlight and Play's Data Safety form remain maintainer-blocked. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 115 ++++++++++++++++ CHANGELOG.md | 36 +++++ android/app/build.gradle.kts | 13 ++ .../rustysnes/MobileCoreSmokeTest.kt | 82 +++++++++++ docs/app-store-4-7-self-audit.md | 130 ++++++++++++++++++ docs/mobile-readiness.md | 51 +++++-- 6 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt create mode 100644 docs/app-store-4-7-self-audit.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4230e939..b8125139 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -255,6 +255,44 @@ jobs: done exit "$fail" + # The RELEASE build path, and it is deliberately UNSIGNED. + # + # Signing material is the project owner's to provision and this workflow does not hold it, so + # a *signed* release build is out of reach here. An unsigned one is not: `assembleRelease` + # still runs R8, resource shrinking and the release manifest merge, which is where + # release-only breakage actually lives -- a missing keep rule strips a class the UniFFI + # bindings reach reflectively, and the debug build never notices because it does not minify. + # + # `isMinifyEnabled` is `false` in `app/build.gradle.kts` today, so this currently proves the + # release variant assembles at all. It is wired now rather than when minification is turned + # on, because the moment it is turned on this step is what catches the fallout. + - name: Assemble the release APK (unsigned) + working-directory: android + env: + RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" + run: ./gradlew --no-daemon assembleRelease + + # The same 16 KB gate as the debug APK's, on the release variant. Not redundant: the release + # variant has its own packaging and its own shrinking, so alignment has to be proven on the + # artifact that would actually ship, not inferred from the one that would not. + - name: Assert 16 KB page alignment inside the RELEASE APK + run: | + set -euo pipefail + apk=$(find android/app/build/outputs/apk/release -name '*.apk' | head -1) + test -n "$apk" || { echo "no release APK was produced"; exit 1; } + work=$(mktemp -d) + unzip -q "$apk" 'lib/*' -d "$work" + fail=0 + for so in "$work"/lib/arm64-v8a/*.so "$work"/lib/x86_64/*.so; do + [ -e "$so" ] || continue + align=$(readelf -lW "$so" | awk '$1 == "LOAD" { print $NF; exit }') + case "$align" in + 0x4000|0x10000) echo "OK $(basename "$so") $align" ;; + *) echo "FAIL $(basename "$so") $align (need 16 KB or larger)"; fail=1 ;; + esac + done + exit "$fail" + - name: Upload the APK uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -262,3 +300,80 @@ jobs: path: android/app/build/outputs/apk/debug/*.apk if-no-files-found: error retention-days: 14 + + # The UniFFI RUNTIME smoke test, in its own job on purpose. + # + # `build` above proves the bindings COMPILE -- `MainActivity` calls `MobileCore` directly, so + # bindgen output that drifted from the Rust API fails the Kotlin compile there. What no build can + # prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that JNA's mapping + # matches the symbols in it, and that a call marshals across and returns. This project has already + # shipped one native Android crash that a build could not have caught. + # + # A separate job, not another step in `build`: an emulator is the flakiest thing in this workflow, + # and a flaky step inside `build` would put the 16 KB alignment gates -- which are not flaky, and + # which gate a real Play requirement -- behind an AVD boot. + # + # x86_64 only. The emulator runs the host ABI; the other three ABIs are covered by `build`'s + # alignment gates, which read ELF headers directly and need no device. + smoke: + runs-on: ubuntu-latest + env: + CARGO_NET_RETRY: "10" + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: ./.github/actions/rust-setup + + - name: Add the x86_64 Android target + run: rustup target add x86_64-linux-android + + # The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT + # on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the + # hard way on its first run. Two jobs building the same libraries with different NDKs would + # also make a divergence between them impossible to attribute. + - name: Install the NDK from the runner's Android SDK + run: | + set -euo pipefail + : "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}" + sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdk" ]; then + echo "::error::no sdkmanager at $sdk" + ls -la "$ANDROID_HOME/cmdline-tools" || true + exit 1 + fi + "$sdk" --install "ndk;27.2.12479018" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked --version ^3 + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v5 + with: + distribution: temurin + java-version: "17" + + # KVM has to be enabled explicitly on GitHub's Linux runners, or the AVD falls back to + # software rendering and the boot times out rather than failing with a clear reason. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # `RUSTFLAGS` for the same reason the `build` job sets it on its Gradle step: Gradle's + # `cargoNdkBuild` re-runs `cargo ndk` in its own process and inherits this environment, not + # the flags of any earlier step. + - name: Run the instrumented UniFFI smoke test + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + env: + RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" + with: + api-level: 34 + arch: x86_64 + target: google_apis + disable-animations: true + working-directory: android + script: ./gradlew --no-daemon connectedDebugAndroidTest diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ceb6a2..bbc3915c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`v1.30.0` mobile store-readiness: the App Store §4.7 self-audit, a release build path, and a + UniFFI runtime smoke test — plus a correction to four stale claims in the readiness doc.** + + **The §4.7 self-audit** (`docs/app-store-4-7-self-audit.md`) is the item `docs/mobile-readiness.md` + recorded as outstanding, and it is the only one of the store-facing items that is *not* + maintainer-blocked. It passes on all five criteria, and the strongest evidence is capability rather + than intent: **Android declares no permissions at all — not even `INTERNET`** — and iOS has no + networking code, so neither shell *can* obtain game software. Every user-visible string in both + shells was enumerated; the complete set is `RustySNES`, `Open ROM`, `Save State`, `Load State`. Two + re-audit triggers are recorded: the peripheral UI when it lands (Super Scope / Mouse / Multitap + names are a fresh trademark decision, and the audit does not pre-approve them) and + `rustysnes-monetization` if it is ever activated. + + **An unsigned `assembleRelease` path**, with its own 16 KB alignment gate on the release APK. + Signing material is the maintainer's to provision, so a *signed* release build stays out of reach — + but an unsigned one still runs R8, resource shrinking and the release manifest merge, which is + where release-only breakage lives. `isMinifyEnabled` is `false` today, so this currently proves the + release variant assembles; it is wired now because the moment minification is enabled, this is what + catches the fallout. + + **An instrumented UniFFI smoke test** (`android/app/src/androidTest`), in its own CI job. + `assembleDebug` already proves the bindings *compile* — `MainActivity` calls `MobileCore` directly. + What no build can prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that + JNA's mapping matches its symbols, and that a call marshals across and returns. This project has + already shipped one native Android crash a build could not have caught. It is a separate job + because an emulator is the flakiest thing in that workflow, and a flaky step inside `build` would + put the 16 KB gates — which are not flaky and do gate a real Play requirement — behind an AVD boot. + + **Four entries in the readiness doc's deferred list had gone stale** and are now marked DONE rather + than deleted, because a readiness document that silently drops items cannot be audited backwards: + `android.yml` exists and gates alignment twice, the `./gradlew` wrapper is committed, `ios.yml` + boots a simulator and requires the app to survive the launch, and the §4.7 audit is done. What + remains genuinely outstanding is stated as such — distribution signing, TestFlight, and Play's Data + Safety form, all maintainer-blocked. **Mobile Phase 6 stays NOT GREENLIT**; passing this audit + removes a prerequisite from that gate's checklist, it does not move the gate. + - **`A6.15` — every 65C816 opcode is defined, and only `STP` hangs. Coverage 361 of 443.** The row executes each of the 241 straight-line opcodes in a WRAM sandbox and counts three outcomes against the length **Table 5-4 of the WDC W65C816S datasheet** documents: returned where it should, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78e3c2e2..60213558 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,6 +18,7 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.18.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -47,6 +48,12 @@ android { getByName("main") { jniLibs.srcDirs("src/main/jniLibs") } + // The instrumented UniFFI smoke test needs the same generated bindings the app uses -- + // `androidTest` compiles as its own variant and does not inherit `main`'s generated + // sources automatically. + getByName("androidTest") { + kotlin.srcDirs("src/androidTest/kotlin") + } } } @@ -130,6 +137,12 @@ tasks.named("preBuild") { } dependencies { + // The instrumented UniFFI smoke test (`src/androidTest`). It proves the generated bindings + // LOAD and CALL on a device, which a build cannot: `assembleDebug` already proves they + // compile, because `MainActivity` calls `MobileCore` directly. + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") + implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") diff --git a/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt new file mode 100644 index 00000000..6a586d72 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt @@ -0,0 +1,82 @@ +package com.doublegate.rustysnes + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import uniffi.rustysnes_mobile.MobileCore +import uniffi.rustysnes_mobile.MobileRegion + +/** + * The UniFFI smoke test: proves the generated Kotlin bindings actually **load and call** the native + * library on a real Android runtime. + * + * `assembleDebug` already proves the bindings *compile* against the shell — `MainActivity` calls + * `MobileCore` directly, so a bindgen output that drifted from the Rust API fails the Kotlin + * compile. What a build cannot prove is that `System.loadLibrary` finds the `.so` for the device's + * ABI, that JNA's mapping matches the symbols in it, and that a call marshals across and returns. + * Those are runtime facts, and this project has already shipped one native Android crash that a + * build could not have caught. + * + * Deliberately ROM-free. The app takes ROMs only from the user's document picker + * (`docs/app-store-4-7-self-audit.md`), so there is no ROM to open here and no need for one: every + * assertion below is about the *bridge*, not about emulation, which the workspace's own test suite + * covers far better than an emulator can. + */ +@RunWith(AndroidJUnit4::class) +class MobileCoreSmokeTest { + /** Constructing the core loads the library and crosses the FFI boundary once. */ + @Test + fun the_native_library_loads_and_a_core_can_be_constructed() { + val core = MobileCore(MobileRegion.NTSC) + assertFalse("a freshly constructed core must report no ROM loaded", core.romLoaded()) + } + + /** + * A frame with no ROM loaded still has to return a correctly sized framebuffer. This is the + * assertion that would catch a marshalling error: a wrong length, or a returned buffer that + * does not survive the crossing, shows up here and nowhere in a build. + */ + @Test + fun a_frame_runs_and_returns_a_framebuffer_of_the_declared_size() { + val core = MobileCore(MobileRegion.NTSC) + core.runFrame() + + val size = core.frameSize() + assertTrue("frame width must be positive, got ${size.width}", size.width > 0u) + assertTrue("frame height must be positive, got ${size.height}", size.height > 0u) + + val fb = core.framebuffer() + assertEquals( + "the framebuffer length must be width * height * 4 (RGBA8)", + (size.width * size.height * 4u).toInt(), + fb.size, + ) + } + + /** + * `drainAudio` is documented as non-destructive — it returns the current frame's buffered + * samples rather than popping a FIFO — so calling it twice for one frame returns the same + * count. Pinning that here is what stops the contract drifting under a shell that calls it once + * per `runFrame` and would not notice. + */ + @Test + fun drain_audio_is_non_destructive_within_a_frame() { + val core = MobileCore(MobileRegion.NTSC) + core.runFrame() + val first = core.drainAudio().size + val second = core.drainAudio().size + assertEquals("drainAudio must not consume the buffer", first, second) + } + + /** Reset and power-cycle are the two lifecycle calls the shell makes; both must cross safely. */ + @Test + fun the_lifecycle_calls_cross_the_boundary() { + val core = MobileCore(MobileRegion.NTSC) + core.reset() + core.powerCycle() + assertFalse("no ROM was ever loaded", core.romLoaded()) + } +} diff --git a/docs/app-store-4-7-self-audit.md b/docs/app-store-4-7-self-audit.md new file mode 100644 index 00000000..e1cb83dd --- /dev/null +++ b/docs/app-store-4-7-self-audit.md @@ -0,0 +1,130 @@ +# App Store §4.7 self-audit — mobile shells + +**Audit date:** 2026-08-02 · **Audited revision:** `main` at the `A6.15` merge (#331) · +**Result: PASS on every criterion checked, with two items flagged for re-audit before submission.** + +This is the formal §4.7 self-audit `docs/mobile-readiness.md` recorded as outstanding. It is an +audit **against the shipped UI**, not an assertion about intent: every finding below cites the file +and line it was read from, so a reviewer can re-run it rather than take it on trust. + +It is **not** a legal opinion, and it is **not** the store-launch gate. Mobile Phase 6 remains +**NOT GREENLIT** — see `docs/mobile-readiness.md`. Passing this audit removes one prerequisite from +that gate's checklist; it does not move the gate. + +## Scope + +App Store Review Guideline **§4.7 (Mini apps, mini games, streaming games, chatbots, plug-ins and +game emulators)** permits retro game console emulator apps, subject to the software offered inside +them being lawful and, in practice, user-provided. Google Play's equivalent position is narrower in +form but the same in substance for this app: the emulator itself is not the problem, the content +supply is. + +Separately from §4.7, **trademark exposure** is its own App Review and legal concern — an emulator +that names or depicts a console manufacturer's marks invites a rejection that has nothing to do with +§4.7. Both are audited here because both are decided by the same shipped strings. + +## Criterion 1 — the app ships no game software · **PASS** + +No ROM, BIOS, or firmware image is bundled in either shell. + +```text +find android/app/src/main -iname '*.sfc' -o -iname '*.smc' -o -path '*assets*' -> nothing +find ios -iname '*.sfc' -o -iname '*.smc' -> nothing +``` + +Android has no `assets/` directory at all. The `jniLibs` the APK does carry are this project's own +`.so` builds (`android/app/build.gradle.kts:46-49`), produced at build time and deliberately not +checked in. + +## Criterion 2 — every ROM is user-supplied, through the system picker · **PASS** + +There is exactly one way a ROM enters either app, and it is the platform's own document picker. The +user chooses a file they already possess; the app never names, suggests, or reaches for a source. + +| shell | mechanism | file:line | +|---|---|---| +| Android | `ActivityResultContracts.OpenDocument()`, read via `contentResolver.openInputStream` | `MainActivity.kt:73`, `:173` | +| iOS | SwiftUI `.fileImporter`, read under `startAccessingSecurityScopedResource()` | `ContentView.swift:52`, `EmulatorViewModel.swift:26` | + +The iOS path going through a security-scoped resource is the correct sandbox behaviour for a +user-selected file and is worth noting as evidence the picker is genuine rather than decorative. + +## Criterion 3 — no capability to obtain game software · **PASS** + +Neither shell can fetch anything. + +- **Android declares no permissions at all** — `AndroidManifest.xml` contains no + `` element, so not even `INTERNET`. An app without `INTERNET` cannot download a + ROM by any route. +- **iOS has no networking code and no ATS configuration.** The only `http` string anywhere in the + bundle is the `DOCTYPE` URL in `Info.plist`'s XML preamble (`Info.plist:2`) — a schema identifier, + not a request. + +This is the strongest single fact in the audit: the "user-provided software" requirement is +enforced by the app's *capabilities*, not merely by its UI. + +## Criterion 4 — no third-party trademark exposure · **PASS** + +Every user-visible string in both shells was enumerated. In full: + +| string | where | +|---|---| +| `RustySNES` | `AndroidManifest.xml:6` (`android:label`), `Info.plist` (`CFBundleDisplayName`) | +| `Open ROM` | `MainActivity.kt:346`, `ContentView.swift:33` | +| `Save State` | `MainActivity.kt:348`, `ContentView.swift:35` | +| `Load State` | `MainActivity.kt:351`, `ContentView.swift:37` | + +That is the complete set. A search of both shells for `nintendo`, `super nintendo`, `famicom`, +`snes`, `super scope`, `multitap`, `game boy`, `mario` and `zelda` returns **nothing** outside this +project's own `RustySNES` / `com.doublegate` identifiers. + +Two points worth stating rather than leaving implicit: + +- **`RustySNES` is the app name, and `SNES` inside it is the thing to watch.** It is the project's + own established name, used consistently and not styled to resemble any manufacturer's mark, and + the app makes no claim of affiliation. It is nonetheless the one string in the audit that touches + a third-party mark at all, and it is named here so a future reviewer sees it was considered rather + than missed. +- **The peripheral names are not exposed.** `Super Scope`, `Multitap` and `Mouse` appear in the + emulator core and in `rustysnes-mobile`'s API, but no mobile shell surfaces them — because the + peripheral picker UI does not exist yet (`docs/mobile-readiness.md`, "Mouse/Super Scope touch UX"). + **This is the audit's main re-audit trigger:** the moment that UI lands, this criterion has to be + re-run, and the naming chosen then is a decision this audit does not pre-approve. + +## Criterion 5 — no monetization surface, so no §3.1 interaction · **PASS** + +`rustysnes-monetization` is compiled into both shells and is **inert**: no Play Billing client, no +StoreKit, no purchase or product call reaches either shell. + +```text +grep -rn 'BillingClient|StoreKit|purchase|SKProduct' android/app/src/main/kotlin ios/RustySNES/Sources + -> nothing +``` + +The crate's own module doc states the dormancy as the design (`crates/rustysnes-monetization/src/lib.rs:6-10`). +Because nothing is sold and no ads are served, §3.1 (In-App Purchase) and the ad-disclosure rules do +not engage at this revision. **Re-audit trigger:** activating it changes that immediately, and the +dormant-vs-live decision is explicitly part of the `v2.0.0` scope. + +## Findings + +**No blocking findings.** Two items to re-audit before any submission, both flagged above: + +1. **The peripheral UI, when it lands** — Super Scope / Mouse / Multitap affordances will need + user-visible names, and those names are a fresh trademark decision. +2. **`rustysnes-monetization`, if activated** — engages §3.1 and the ad-disclosure rules that this + revision does not touch. + +Neither is a defect. Both are consequences of work that is deliberately not done yet, and recording +them here is what stops a future rung from shipping them past an audit that predates them. + +## What this audit does not cover + +Out of scope by construction, and each is maintainer-blocked rather than merely undone: + +- **Distribution signing and TestFlight** — the `ios.yml` upload step is an explicit no-op pending + real signing secrets. +- **Google Play's Data Safety form** — a console declaration, not a code property. Note the audit + above supplies its likely content: no permissions, no network, no data leaves the device. +- **The store-submission readiness assessment itself** — Mobile Phase 6, an explicit maintainer + go/no-go, scoped to `v2.0.0`. diff --git a/docs/mobile-readiness.md b/docs/mobile-readiness.md index 0c10622c..12a7c65f 100644 --- a/docs/mobile-readiness.md +++ b/docs/mobile-readiness.md @@ -214,6 +214,11 @@ Netplay is a large, net-new UI surface neither shell has any precedent for. See ## Not yet verified / explicitly deferred +> **Audited 2026-08-02 (`v1.30.0`).** Four entries in this list had gone stale — `android.yml`, the +> `./gradlew` wrapper, the iOS simulator *run*, and the §4.7 self-audit all exist now. They are kept +> below, marked **DONE**, rather than deleted: a readiness document that silently drops items is one +> nobody can audit backwards. What remains genuinely outstanding is stated as such. + - **Mouse/Super Scope touch UX — the arithmetic is done, the on-screen controls are not.** `rustysnes-mobile::touch` maps a touch through the letterboxed viewport into SNES screen space (`map_touch_to_screen`) and turns a drag into Mouse counts with a carried residual (`TouchMouse`), @@ -223,21 +228,37 @@ Netplay is a large, net-new UI surface neither shell has any precedent for. See `cargo test` failure rather than a user aiming half a screen off. Still outstanding: the on-screen affordances themselves (a Scope reticle, Mouse button targets, a peripheral picker) and Multitap, whose port assignment is UI, not arithmetic. P1 standard gamepad remains the only wired input. -- **No `android.yml` CI workflow yet** — NDK cross-build, UniFFI Kotlin smoke test, 16KB ELF - page-alignment check, dormant Play-flavor Gradle split — `v1.15.1+`. -- **No checked-in `./gradlew` wrapper yet** — this environment used its locally cached Gradle - 8.11 distribution directly; a proper wrapper should still be generated/committed for - reproducibility — `v1.15.1+`. -- **No on-device or simulator *run* has happened — only a build.** This development environment - has no macOS/Xcode toolchain at all, so nothing here can be run interactively; - `.github/workflows/ios.yml`'s `macos-latest` job (real `xcodegen generate` + unsigned - `xcodebuild` simulator build) is the only real verification this Swift/Xcode code has ever had, - and it now genuinely passes (see "Verified so far" above) — but a passing build proves the code - compiles and links, not that it behaves correctly at runtime (no ROM has ever actually booted - here). -- **No TestFlight upload, no App Store §4.7 self-audit, no real distribution signing** — the - `ios.yml` step exists but is an explicit no-op pending the project owner provisioning real - signing secrets. +- **`android.yml` exists and gates 16 KB alignment twice — DONE (`v1.30.0` audit).** The workflow + cross-builds the JNI libraries for all four ABIs, asserts 16 KB ELF page alignment on the 64-bit + `.so`s *and* again inside the packaged APK, confirms the APK carries every ABI, and uploads it. + The APK-level gate is not redundant: Gradle's `cargoNdkBuild` re-runs `cargo ndk` in its own + process and does **not** inherit `RUSTFLAGS` from the earlier steps, so without it the APK ships + 4 KB-aligned libraries while the standalone build produced 16 KB ones — the exact false pass the + second gate now catches. +- **A `./gradlew` wrapper is checked in — DONE.** `android/gradlew`, `android/gradlew.bat` and + `android/gradle/wrapper/` are all in the repo, and CI builds through the wrapper rather than a + locally cached distribution. +- **An unsigned `assembleRelease` path exists — DONE (`v1.30.0`).** Signing material is the project + owner's to provision, so a *signed* release build is still out of reach; an unsigned one is not, + and it runs R8, resource shrinking and the release manifest merge — where release-only breakage + actually lives. `isMinifyEnabled` is `false` today, so at this revision it proves the release + variant assembles; it is wired now because the moment minification is enabled this step is what + catches the fallout. The release APK gets its own 16 KB gate, on the artifact that would ship. +- **iOS runs in a simulator, not just a build — DONE.** `.github/workflows/ios.yml` picks an + available iPhone simulator, boots it (`simctl bootstatus -b`), installs the built `.app`, launches + it and requires it to still be alive afterwards. That closes the "no ROM has ever actually booted + here" gap at the *app-launch* level. It does **not** prove a ROM boots — the simulator run has no + ROM to open, because the app takes ROMs only from the user's document picker (by design; see + `docs/app-store-4-7-self-audit.md`). +- **The App Store §4.7 self-audit is DONE (`v1.30.0`)** — `docs/app-store-4-7-self-audit.md`. It + passes on all five criteria checked, with the strongest evidence being capability rather than + intent: Android declares **no permissions at all**, not even `INTERNET`, and iOS has no networking + code, so neither shell *can* obtain game software. Two re-audit triggers are recorded there (the + peripheral UI when it lands, and `rustysnes-monetization` if activated). +- **Still genuinely outstanding: distribution signing and TestFlight** — the `ios.yml` upload step + is an explicit no-op pending the project owner provisioning real signing secrets, and Play's Data + Safety form is a console declaration rather than a code property. The §4.7 audit above supplies + that form's likely content: no permissions, no network, no data leaving the device. - **No store-submission readiness assessment yet** — see "Mobile Phase 6 — store-launch gate status" below for the full gate criteria and current disposition. From b05b71436a9e3d2ed2a07767aaeb06ead6204cf1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 14:04:48 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(ci):=20the=20smoke=20job=20needs=20arm6?= =?UTF-8?q?4=20too=20=E2=80=94=20cargoNdkBuild=20builds=20every=20ABI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job failed on its first run with `can't find crate for core: the aarch64-linux-android target may not be installed`. The reasoning in the comment was wrong, not just the target list. "x86_64 only, the emulator runs the host ABI" confuses what the emulator RUNS with what the build COMPILES: Gradle's cargoNdkBuild builds every ABI in build.gradle.kts's `cargoAbis` map -- arm64-v8a AND x86_64 -- before the instrumented test can install anything, so both Rust targets have to be present regardless of which one the device is. Also salvages this project's agent scratch out of /tmp ahead of a reboot: 40 one-off probe scripts and 5 documents into `salvaged/`, which is gitignored, so the only tracked change is docs/SALVAGE_MANIFEST.md. Dropped ROM-derived framebuffer captures, regenerable AccuracySNES scene captures, ~1.4 MB of CHANGELOG working copies, release notes already in CHANGELOG.md, and ~45 PR bodies and bot-review adjudications for merged PRs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 12 ++++--- docs/SALVAGE_MANIFEST.md | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b8125139..2cfbfdca 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -313,8 +313,12 @@ jobs: # and a flaky step inside `build` would put the 16 KB alignment gates -- which are not flaky, and # which gate a real Play requirement -- behind an AVD boot. # - # x86_64 only. The emulator runs the host ABI; the other three ABIs are covered by `build`'s - # alignment gates, which read ELF headers directly and need no device. + # The emulator runs x86_64, but that is NOT the set of Rust targets this job needs. Gradle's + # `cargoNdkBuild` builds every ABI in `app/build.gradle.kts`'s `cargoAbis` map -- arm64-v8a AND + # x86_64 -- before the instrumented test can install anything, so both targets must be installed + # or the task fails on `can't find crate for core`. Installing only x86_64 because "the emulator + # runs the host ABI" confuses what the emulator RUNS with what the build COMPILES; that is + # exactly how this job failed on its first run. smoke: runs-on: ubuntu-latest env: @@ -327,8 +331,8 @@ jobs: - uses: ./.github/actions/rust-setup - - name: Add the x86_64 Android target - run: rustup target add x86_64-linux-android + - name: Add the Android targets Gradle's cargoNdkBuild needs + run: rustup target add x86_64-linux-android aarch64-linux-android # The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT # on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the diff --git a/docs/SALVAGE_MANIFEST.md b/docs/SALVAGE_MANIFEST.md index 16edf02f..381b4582 100644 --- a/docs/SALVAGE_MANIFEST.md +++ b/docs/SALVAGE_MANIFEST.md @@ -18,3 +18,70 @@ references. Salvaged those; skipped everything else. | `/tmp/rustysnes-research/nesdev_timing.txt` | `ref-docs/2026-07-22-nesdev-snes-timing.md` | Vendored verbatim; CC BY-SA 4.0, reference-only | `ref-docs/README.md` index updated with both entries. Nothing else moved. + +## 2026-08-02T13:58:17 — agent scratch, curated + +Moved **40** one-off probe/debug scripts out of this project's agent scratch tree into `salvaged/scripts/`. + +Curation applied on top of `--no-weak`: dropped ROM-derived captures (`smw_*.ppm`), regenerable +AccuracySNES scene captures (`s9.scene*.bin`), ares/NDK build detritus (`*.cmake`, `CMakeFiles/`, +CMake compiler-ID probes), and 6 files whose names are already tracked in the repo. + +- `salvaged/scripts/a5_18-parked.patch` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/a5_18-parked.patch` +- `salvaged/scripts/a5_19-wip.patch` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/a5_19-wip.patch` +- `salvaged/scripts/atdone.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/atdone.lua` +- `salvaged/scripts/base-agy.sh` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/base-agy.sh` +- `salvaged/scripts/capture_with_input.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/capture_with_input.lua` +- `salvaged/scripts/doctor_scan.py` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/951bcba8-82dc-4ed4-8363-b59d0791cee6/scratchpad/doctor_scan.py` +- `salvaged/scripts/e506.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/e506.rs` +- `salvaged/scripts/e801.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/e801.lua` +- `salvaged/scripts/e801.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/e801.rs` +- `salvaged/scripts/e806.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/e806.rs` +- `salvaged/scripts/e902.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/e902.rs` +- `salvaged/scripts/edge_probe.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/edge_probe.lua` +- `salvaged/scripts/fails.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/fails.lua` +- `salvaged/scripts/final.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/final.lua` +- `salvaged/scripts/fixed_crossval.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/fixed_crossval.lua` +- `salvaged/scripts/fixed_scenes.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/fixed_scenes.lua` +- `salvaged/scripts/gh.py` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/951bcba8-82dc-4ed4-8363-b59d0791cee6/scratchpad/gh.py` +- `salvaged/scripts/held_1based.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/held_1based.lua` +- `salvaged/scripts/held_probe.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/held_probe.lua` +- `salvaged/scripts/idx_probe.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/idx_probe.lua` +- `salvaged/scripts/inspect_rom.py` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/inspect_rom.py` +- `salvaged/scripts/liveness.fixed.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/liveness.fixed.rs` +- `salvaged/scripts/lrcv.c` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/lrcv.c` +- `salvaged/scripts/m2list.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/m2list.lua` +- `salvaged/scripts/meas.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/meas.lua` +- `salvaged/scripts/new_rtt_tests.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/new_rtt_tests.rs` +- `salvaged/scripts/old_rtt_tests.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/old_rtt_tests.rs` +- `salvaged/scripts/one.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/one.lua` +- `salvaged/scripts/pad_probe.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/pad_probe.lua` +- `salvaged/scripts/pc.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/pc.lua` +- `salvaged/scripts/pinexact_hash_baseline.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/pinexact_hash_baseline.rs` +- `salvaged/scripts/pr260-fixes.patch` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/pr260-fixes.patch` +- `salvaged/scripts/probe.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/probe.rs` +- `salvaged/scripts/resolve.py` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/resolve.py` +- `salvaged/scripts/run-guardtest.sh` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/run-guardtest.sh` +- `salvaged/scripts/run-nightlytest.sh` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/run-nightlytest.sh` +- `salvaged/scripts/shift.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/shift.rs` +- `salvaged/scripts/spc_probe.rs` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/spc_probe.rs` +- `salvaged/scripts/wire_i18n.py` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/wire_i18n.py` +- `salvaged/scripts/wram_probe.lua` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/wram_probe.lua` + + +## 2026-08-02T14:01:40 — agent scratch, docs (hand-picked) + +Moved **5** files to `salvaged/docs/`. The other ~54 `docs/` candidates were dropped: +~45 PR bodies and bot-review adjudications for merged PRs, three working copies of the repo's own +CHANGELOG (~1.4 MB), release notes for v1.22-v1.25 already in `CHANGELOG.md` and on GitHub +Releases, and the dry-run output of this salvage itself. + +Note two of these five turned out to be bot-review adjudications rather than durable prose +(`adjudication.md`, `c258.md`); kept anyway, since `salvaged/` is gitignored and the cost is nil. + +- `salvaged/docs/adjudication.md` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/adjudication.md` +- `salvaged/docs/c258.md` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/c258.md` +- `salvaged/docs/netplay.md` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/split/netplay.md` +- `salvaged/docs/plan_section.md` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/plan_section.md` +- `salvaged/docs/stack-tips.txt` <- `/tmp/claude-1000/-home-parobek-Code-OSS-Public-Projects-RustySNES/fc11bae0-ecd6-457a-ac1b-be930be88017/scratchpad/stack-tips.txt` + From ec27cbf3438ff1f3dc1627197c792a9c6ca3e2d2 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 14:59:41 -0400 Subject: [PATCH 3/4] fix(mobile): resolve the review findings, including a vacuous smoke assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all real. CRITICAL -- the release-APK alignment gate could pass on an APK that would fail on Play. I re-implemented the check instead of reusing the logic the debug gate already settled, and got all three of its properties wrong: 1. exact string equality (`0x4000|0x10000`) rejects other valid alignments -- the requirement is "at least 16 KB", so it is DIVISIBILITY. The debug gate's own comment records this exact mistake: an equality test wrongly flagged JNA's 64 KB-aligned libjnidispatch.so. 2. `awk ... exit` read only the FIRST LOAD segment. 3. no `checked == 0` guard, so a missing ABI directory makes the glob match nothing and the gate PASSES on an APK with no libraries in it. The gate now uses the same aligned16k() helper and the same guard as the debug one. Repeating a bug the file already documents having fixed is the argument for reusing settled logic rather than writing it again. VACUOUS TEST -- drain_audio_is_non_destructive_within_a_frame asserted that two successive drainAudio() calls return the same count, claiming to pin the documented non-destructive contract. Measured on the host: a no-ROM frame produces NO audio, `first=0 second=0`, so `0 == 0` passed and proved nothing -- it would have gone on passing had the contract inverted. Replaced with an assertion the bridge can actually support without a ROM (interleaved stereo, so an even length); the real contract stays covered host-side by drain_audio_returns_interleaved_stereo_samples, which loads a ROM first. Also: the §4.7 audit's monetization grep lacked -E, so its `|` were literal and it would have reported "nothing" for a reason unrelated to the code -- an audit whose command cannot be re-run is an assertion, not an audit. And the emulator step is now bounded by timeout-minutes, since splitting the job contained the blast radius of a flaky AVD without bounding its runtime. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 43 ++++++++++++++++--- .../rustysnes/MobileCoreSmokeTest.kt | 27 ++++++++---- docs/app-store-4-7-self-audit.md | 6 ++- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2cfbfdca..29240448 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -282,15 +282,40 @@ jobs: test -n "$apk" || { echo "no release APK was produced"; exit 1; } work=$(mktemp -d) unzip -q "$apk" 'lib/*' -d "$work" + # The SAME three properties the debug gate above settled on, and the first version of + # this step got all three wrong by re-implementing instead of reusing: + # 1. DIVISIBILITY, not string equality. `0x8000` and `0x20000` are valid -- the + # requirement is "at least 16 KB". The debug gate's own comment records that an + # equality test wrongly flagged JNA's 64 KB-aligned libjnidispatch.so. + # 2. EVERY LOAD segment, not just the first (`awk ... exit` read one and stopped). + # 3. A `checked == 0` guard. Without it a missing ABI directory makes the glob match + # nothing and the gate PASSES on an APK with no libraries in it at all. + aligned16k() { + local a + for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do + [ $(( a % 16384 )) -eq 0 ] || return 1 + done + return 0 + } fail=0 - for so in "$work"/lib/arm64-v8a/*.so "$work"/lib/x86_64/*.so; do - [ -e "$so" ] || continue - align=$(readelf -lW "$so" | awk '$1 == "LOAD" { print $NF; exit }') - case "$align" in - 0x4000|0x10000) echo "OK $(basename "$so") $align" ;; - *) echo "FAIL $(basename "$so") $align (need 16 KB or larger)"; fail=1 ;; - esac + checked=0 + for abi in arm64-v8a x86_64; do + for so in "$work"/lib/"$abi"/*.so; do + [ -e "$so" ] || { echo "::error::the release APK carries no .so for $abi"; exit 1; } + checked=$((checked + 1)) + if aligned16k "$so"; then + echo "ok: $abi/$(basename "$so")" + else + echo "::error::$so has LOAD segment(s) not a multiple of 16 KB" + readelf -lW "$so" | awk '$1 == "LOAD"' + fail=1 + fi + done done + if [ "$checked" -eq 0 ]; then + echo "::error::the release alignment gate checked nothing" + exit 1 + fi exit "$fail" - name: Upload the APK @@ -370,7 +395,11 @@ jobs: # `RUSTFLAGS` for the same reason the `build` job sets it on its Gradle step: Gradle's # `cargoNdkBuild` re-runs `cargo ndk` in its own process and inherits this environment, not # the flags of any earlier step. + # Bounded, for the reason the job exists: the split contains the blast radius of a flaky + # emulator but does not bound its runtime, and an AVD that never reaches boot-complete would + # otherwise burn the whole job timeout. The observed run is ~6.5 minutes. - name: Run the instrumented UniFFI smoke test + timeout-minutes: 25 uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" diff --git a/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt index 6a586d72..271db6f7 100644 --- a/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt +++ b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt @@ -57,18 +57,29 @@ class MobileCoreSmokeTest { } /** - * `drainAudio` is documented as non-destructive — it returns the current frame's buffered - * samples rather than popping a FIFO — so calling it twice for one frame returns the same - * count. Pinning that here is what stops the contract drifting under a shell that calls it once - * per `runFrame` and would not notice. + * `drainAudio` crosses the boundary and returns a well-formed **interleaved stereo** buffer. + * + * It asserts an even length, not a non-destructive contract. The first version of this test + * called `drainAudio` twice and asserted the two counts matched, claiming to pin the documented + * non-destructive behaviour — but a no-ROM frame produces **no audio at all** (measured: + * `first=0 second=0`), so `0 == 0` passed and proved nothing. That is a vacuous test: it would + * have gone on passing had the contract inverted. + * + * The real contract is covered where it can be, host-side, by + * `rustysnes-mobile`'s own `drain_audio_returns_interleaved_stereo_samples`, which loads a ROM + * first. This test's job is the bridge, and an even length is what the bridge can actually + * prove without a ROM the picker has not been given. */ @Test - fun drain_audio_is_non_destructive_within_a_frame() { + fun drain_audio_returns_a_well_formed_interleaved_buffer() { val core = MobileCore(MobileRegion.NTSC) core.runFrame() - val first = core.drainAudio().size - val second = core.drainAudio().size - assertEquals("drainAudio must not consume the buffer", first, second) + val audio = core.drainAudio() + assertEquals( + "drainAudio must return interleaved stereo, so its length is always even", + 0, + audio.size % 2, + ) } /** Reset and power-cycle are the two lifecycle calls the shell makes; both must cross safely. */ diff --git a/docs/app-store-4-7-self-audit.md b/docs/app-store-4-7-self-audit.md index e1cb83dd..7ff8801a 100644 --- a/docs/app-store-4-7-self-audit.md +++ b/docs/app-store-4-7-self-audit.md @@ -97,10 +97,14 @@ Two points worth stating rather than leaving implicit: StoreKit, no purchase or product call reaches either shell. ```text -grep -rn 'BillingClient|StoreKit|purchase|SKProduct' android/app/src/main/kotlin ios/RustySNES/Sources +grep -rnE 'BillingClient|StoreKit|purchase|SKProduct' android/app/src/main/kotlin ios/RustySNES/Sources -> nothing ``` +`-E`, and that matters for reproducibility: without it `grep` reads the `|` literally and matches only +the whole string, so the command would report "nothing" for a reason that has nothing to do with the +code. An audit whose command cannot be re-run is an assertion, not an audit. + The crate's own module doc states the dormancy as the design (`crates/rustysnes-monetization/src/lib.rs:6-10`). Because nothing is sold and no ads are served, §3.1 (In-App Purchase) and the ad-disclosure rules do not engage at this revision. **Re-audit trigger:** activating it changes that immediately, and the From f05e357b563e8d6e2a40a5dfd00cb734fa2e1c2b Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 2 Aug 2026 15:53:22 -0400 Subject: [PATCH 4/4] fix(mobile): boot a real cart in the smoke test, so its assertions can fail The audio assertion took two wrong turns of the same kind, and the reviewer was right both times. It first asserted two successive drainAudio() calls return equal counts (claiming the non-destructive contract), then that the buffer length is even (claiming interleaved stereo). A no-ROM frame produces NO audio, measured on the host as first=0 second=0 -- so the first passed on `0 == 0` and the second on `0 % 2 == 0`. Neither could fail for the reason it named. The fix is not a better assertion, it is a cart. AccuracySNES's HiROM image is 64 KB, tracked, and dual-licensed with the repo, so unlike any commercial ROM it can be packaged into a test APK. Gradle's `copyTestRom` places it as an androidTest asset (gitignored -- build output, matching how jniLibs is handled) and the test loads it, asserts it loaded, runs frames, and asserts real audio crossed the bridge. That also closes something docs/mobile-readiness.md records as never having happened: no ROM had ever actually booted on a device or simulator. It has now. Verified on the host before wiring it, because an assertion nobody has watched pass is the same gamble as one that cannot fail: a booted cart emits 1066 interleaved samples from the FIRST frame. The eight frames are margin, and the comment says so rather than repeating my initial guess that the APU needs the IPL handshake to complete first -- which the measurement disproves. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++ android/app/build.gradle.kts | 21 +++++++++- .../rustysnes/MobileCoreSmokeTest.kt | 38 +++++++++++++------ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index d846901a..ec18281a 100644 --- a/.gitignore +++ b/.gitignore @@ -274,3 +274,7 @@ WARP.md **/*.cgp **/*.sym /presets/ + +# Copied in by Gradle's `copyTestRom` from tests/roms/AccuracySNES/build -- build output, +# not source, matching how android/app/src/main/jniLibs is handled. +/android/app/src/androidTest/assets/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 60213558..55172dc4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -53,6 +53,9 @@ android { // sources automatically. getByName("androidTest") { kotlin.srcDirs("src/androidTest/kotlin") + // The instrumented smoke test loads a REAL cart, so it needs one packaged with it. + // Copied in by `copyTestRom` below rather than checked in twice. + assets.srcDirs("src/androidTest/assets") } } } @@ -132,8 +135,24 @@ tasks.register("uniffiBindgenMonetization") { android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi/uniffi") android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi-monetization/uniffi") +// AccuracySNES's HiROM image (64 KB) as an instrumented-test asset. +// +// This project's own cart, dual-licensed with the repo, so unlike every commercial ROM it can be +// packaged into a test APK. It is what turns the smoke test from "the bindings load" into "a real +// cart boots on a device" -- `docs/mobile-readiness.md` records that no ROM had ever actually +// booted on a device or simulator, and an emulator bridge test with no ROM cannot fix that. +// +// The HiROM variant, not the 256 KB LoROM one, because the test only needs a cart that runs and +// this is the smallest of the four the generator emits. +val copyTestRom = tasks.register("copyTestRom") { + from(rootProject.projectDir.parentFile.resolve("tests/roms/AccuracySNES/build")) { + include("accuracysnes-hirom.sfc") + } + into(project.projectDir.resolve("src/androidTest/assets")) +} + tasks.named("preBuild") { - dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization") + dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization", copyTestRom) } dependencies { diff --git a/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt index 271db6f7..65419a1e 100644 --- a/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt +++ b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt @@ -1,6 +1,7 @@ package com.doublegate.rustysnes import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -56,25 +57,38 @@ class MobileCoreSmokeTest { ) } + /** AccuracySNES's HiROM image, packaged as a test asset by Gradle's `copyTestRom`. */ + private fun testRom(): ByteArray = + InstrumentationRegistry.getInstrumentation().context.assets + .open("accuracysnes-hirom.sfc").use { it.readBytes() } + /** - * `drainAudio` crosses the boundary and returns a well-formed **interleaved stereo** buffer. + * A **real cart boots on the device**, and only then is the audio buffer asserted. * - * It asserts an even length, not a non-destructive contract. The first version of this test - * called `drainAudio` twice and asserted the two counts matched, claiming to pin the documented - * non-destructive behaviour — but a no-ROM frame produces **no audio at all** (measured: - * `first=0 second=0`), so `0 == 0` passed and proved nothing. That is a vacuous test: it would - * have gone on passing had the contract inverted. + * This test took two wrong turns, both of the same kind. It first asserted that two successive + * `drainAudio()` calls return equal counts, claiming to pin the documented non-destructive + * contract; then that the buffer length is even, claiming to pin interleaved stereo. A no-ROM + * frame produces **no audio at all** (measured on the host: `first=0 second=0`), so the first + * passed on `0 == 0` and the second on `0 % 2 == 0`. Neither could fail for the reason it named. * - * The real contract is covered where it can be, host-side, by - * `rustysnes-mobile`'s own `drain_audio_returns_interleaved_stereo_samples`, which loads a ROM - * first. This test's job is the bridge, and an even length is what the bridge can actually - * prove without a ROM the picker has not been given. + * The fix is not a better assertion, it is a cart. `docs/mobile-readiness.md` records that no + * ROM had ever actually booted on a device or simulator; loading one here closes that and makes + * every assertion below capable of failing. */ @Test - fun drain_audio_returns_a_well_formed_interleaved_buffer() { + fun a_real_cart_boots_and_produces_audio() { val core = MobileCore(MobileRegion.NTSC) - core.runFrame() + core.loadRom(testRom()) + assertTrue("the cart did not load", core.romLoaded()) + + // Eight frames for margin, not because eight are needed: measured on the host, a booted + // AccuracySNES cart emits 1066 interleaved samples from the FIRST frame. Stating that + // rather than a plausible "the APU needs the IPL handshake first" — which the measurement + // disproves — keeps the comment something a reader can rely on. + repeat(8) { core.runFrame() } + val audio = core.drainAudio() + assertTrue("a booted cart produced no audio at all", audio.isNotEmpty()) assertEquals( "drainAudio must return interleaved stereo, so its length is always even", 0,