diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4230e939..29240448 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -255,6 +255,69 @@ 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" + # 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 + 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 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -262,3 +325,88 @@ 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. + # + # 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: + 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 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 + # 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. + # 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" + with: + api-level: 34 + arch: x86_64 + target: google_apis + disable-animations: true + working-directory: android + script: ./gradlew --no-daemon connectedDebugAndroidTest 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/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..55172dc4 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,15 @@ 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") + // 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") + } } } @@ -125,11 +135,33 @@ 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 { + // 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..65419a1e --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/doublegate/rustysnes/MobileCoreSmokeTest.kt @@ -0,0 +1,107 @@ +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 +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, + ) + } + + /** 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() } + + /** + * A **real cart boots on the device**, and only then is the audio buffer asserted. + * + * 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 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 a_real_cart_boots_and_produces_audio() { + val core = MobileCore(MobileRegion.NTSC) + 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, + audio.size % 2, + ) + } + + /** 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/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` + 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..7ff8801a --- /dev/null +++ b/docs/app-store-4-7-self-audit.md @@ -0,0 +1,134 @@ +# 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 -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 +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.