Skip to content

Commit 3d6eeb7

Browse files
doublegateclaude
andauthored
feat(mobile): add the App Store 4.7 self-audit, a release build path, and a UniFFI smoke test (#332)
* 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) <noreply@anthropic.com> * fix(ci): the smoke job needs arm64 too — cargoNdkBuild builds every ABI 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) <noreply@anthropic.com> * fix(mobile): resolve the review findings, including a vacuous smoke assertion 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 368cc9f commit 3d6eeb7

8 files changed

Lines changed: 565 additions & 16 deletions

File tree

‎.github/workflows/android.yml‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,158 @@ jobs:
255255
done
256256
exit "$fail"
257257
258+
# The RELEASE build path, and it is deliberately UNSIGNED.
259+
#
260+
# Signing material is the project owner's to provision and this workflow does not hold it, so
261+
# a *signed* release build is out of reach here. An unsigned one is not: `assembleRelease`
262+
# still runs R8, resource shrinking and the release manifest merge, which is where
263+
# release-only breakage actually lives -- a missing keep rule strips a class the UniFFI
264+
# bindings reach reflectively, and the debug build never notices because it does not minify.
265+
#
266+
# `isMinifyEnabled` is `false` in `app/build.gradle.kts` today, so this currently proves the
267+
# release variant assembles at all. It is wired now rather than when minification is turned
268+
# on, because the moment it is turned on this step is what catches the fallout.
269+
- name: Assemble the release APK (unsigned)
270+
working-directory: android
271+
env:
272+
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
273+
run: ./gradlew --no-daemon assembleRelease
274+
275+
# The same 16 KB gate as the debug APK's, on the release variant. Not redundant: the release
276+
# variant has its own packaging and its own shrinking, so alignment has to be proven on the
277+
# artifact that would actually ship, not inferred from the one that would not.
278+
- name: Assert 16 KB page alignment inside the RELEASE APK
279+
run: |
280+
set -euo pipefail
281+
apk=$(find android/app/build/outputs/apk/release -name '*.apk' | head -1)
282+
test -n "$apk" || { echo "no release APK was produced"; exit 1; }
283+
work=$(mktemp -d)
284+
unzip -q "$apk" 'lib/*' -d "$work"
285+
# The SAME three properties the debug gate above settled on, and the first version of
286+
# this step got all three wrong by re-implementing instead of reusing:
287+
# 1. DIVISIBILITY, not string equality. `0x8000` and `0x20000` are valid -- the
288+
# requirement is "at least 16 KB". The debug gate's own comment records that an
289+
# equality test wrongly flagged JNA's 64 KB-aligned libjnidispatch.so.
290+
# 2. EVERY LOAD segment, not just the first (`awk ... exit` read one and stopped).
291+
# 3. A `checked == 0` guard. Without it a missing ABI directory makes the glob match
292+
# nothing and the gate PASSES on an APK with no libraries in it at all.
293+
aligned16k() {
294+
local a
295+
for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do
296+
[ $(( a % 16384 )) -eq 0 ] || return 1
297+
done
298+
return 0
299+
}
300+
fail=0
301+
checked=0
302+
for abi in arm64-v8a x86_64; do
303+
for so in "$work"/lib/"$abi"/*.so; do
304+
[ -e "$so" ] || { echo "::error::the release APK carries no .so for $abi"; exit 1; }
305+
checked=$((checked + 1))
306+
if aligned16k "$so"; then
307+
echo "ok: $abi/$(basename "$so")"
308+
else
309+
echo "::error::$so has LOAD segment(s) not a multiple of 16 KB"
310+
readelf -lW "$so" | awk '$1 == "LOAD"'
311+
fail=1
312+
fi
313+
done
314+
done
315+
if [ "$checked" -eq 0 ]; then
316+
echo "::error::the release alignment gate checked nothing"
317+
exit 1
318+
fi
319+
exit "$fail"
320+
258321
- name: Upload the APK
259322
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
260323
with:
261324
name: rustysnes-debug-apk
262325
path: android/app/build/outputs/apk/debug/*.apk
263326
if-no-files-found: error
264327
retention-days: 14
328+
329+
# The UniFFI RUNTIME smoke test, in its own job on purpose.
330+
#
331+
# `build` above proves the bindings COMPILE -- `MainActivity` calls `MobileCore` directly, so
332+
# bindgen output that drifted from the Rust API fails the Kotlin compile there. What no build can
333+
# prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that JNA's mapping
334+
# matches the symbols in it, and that a call marshals across and returns. This project has already
335+
# shipped one native Android crash that a build could not have caught.
336+
#
337+
# A separate job, not another step in `build`: an emulator is the flakiest thing in this workflow,
338+
# and a flaky step inside `build` would put the 16 KB alignment gates -- which are not flaky, and
339+
# which gate a real Play requirement -- behind an AVD boot.
340+
#
341+
# The emulator runs x86_64, but that is NOT the set of Rust targets this job needs. Gradle's
342+
# `cargoNdkBuild` builds every ABI in `app/build.gradle.kts`'s `cargoAbis` map -- arm64-v8a AND
343+
# x86_64 -- before the instrumented test can install anything, so both targets must be installed
344+
# or the task fails on `can't find crate for core`. Installing only x86_64 because "the emulator
345+
# runs the host ABI" confuses what the emulator RUNS with what the build COMPILES; that is
346+
# exactly how this job failed on its first run.
347+
smoke:
348+
runs-on: ubuntu-latest
349+
env:
350+
CARGO_NET_RETRY: "10"
351+
CARGO_TERM_COLOR: always
352+
steps:
353+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
354+
with:
355+
persist-credentials: false
356+
357+
- uses: ./.github/actions/rust-setup
358+
359+
- name: Add the Android targets Gradle's cargoNdkBuild needs
360+
run: rustup target add x86_64-linux-android aarch64-linux-android
361+
362+
# The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT
363+
# on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the
364+
# hard way on its first run. Two jobs building the same libraries with different NDKs would
365+
# also make a divergence between them impossible to attribute.
366+
- name: Install the NDK from the runner's Android SDK
367+
run: |
368+
set -euo pipefail
369+
: "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}"
370+
sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
371+
if [ ! -x "$sdk" ]; then
372+
echo "::error::no sdkmanager at $sdk"
373+
ls -la "$ANDROID_HOME/cmdline-tools" || true
374+
exit 1
375+
fi
376+
"$sdk" --install "ndk;27.2.12479018"
377+
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV"
378+
379+
- name: Install cargo-ndk
380+
run: cargo install cargo-ndk --locked --version ^3
381+
382+
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v5
383+
with:
384+
distribution: temurin
385+
java-version: "17"
386+
387+
# KVM has to be enabled explicitly on GitHub's Linux runners, or the AVD falls back to
388+
# software rendering and the boot times out rather than failing with a clear reason.
389+
- name: Enable KVM
390+
run: |
391+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
392+
sudo udevadm control --reload-rules
393+
sudo udevadm trigger --name-match=kvm
394+
395+
# `RUSTFLAGS` for the same reason the `build` job sets it on its Gradle step: Gradle's
396+
# `cargoNdkBuild` re-runs `cargo ndk` in its own process and inherits this environment, not
397+
# the flags of any earlier step.
398+
# Bounded, for the reason the job exists: the split contains the blast radius of a flaky
399+
# emulator but does not bound its runtime, and an AVD that never reaches boot-complete would
400+
# otherwise burn the whole job timeout. The observed run is ~6.5 minutes.
401+
- name: Run the instrumented UniFFI smoke test
402+
timeout-minutes: 25
403+
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
404+
env:
405+
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
406+
with:
407+
api-level: 34
408+
arch: x86_64
409+
target: google_apis
410+
disable-animations: true
411+
working-directory: android
412+
script: ./gradlew --no-daemon connectedDebugAndroidTest

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,7 @@ WARP.md
274274
**/*.cgp
275275
**/*.sym
276276
/presets/
277+
278+
# Copied in by Gradle's `copyTestRom` from tests/roms/AccuracySNES/build -- build output,
279+
# not source, matching how android/app/src/main/jniLibs is handled.
280+
/android/app/src/androidTest/assets/

‎CHANGELOG.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- **`v1.30.0` mobile store-readiness: the App Store §4.7 self-audit, a release build path, and a
15+
UniFFI runtime smoke test — plus a correction to four stale claims in the readiness doc.**
16+
17+
**The §4.7 self-audit** (`docs/app-store-4-7-self-audit.md`) is the item `docs/mobile-readiness.md`
18+
recorded as outstanding, and it is the only one of the store-facing items that is *not*
19+
maintainer-blocked. It passes on all five criteria, and the strongest evidence is capability rather
20+
than intent: **Android declares no permissions at all — not even `INTERNET`** — and iOS has no
21+
networking code, so neither shell *can* obtain game software. Every user-visible string in both
22+
shells was enumerated; the complete set is `RustySNES`, `Open ROM`, `Save State`, `Load State`. Two
23+
re-audit triggers are recorded: the peripheral UI when it lands (Super Scope / Mouse / Multitap
24+
names are a fresh trademark decision, and the audit does not pre-approve them) and
25+
`rustysnes-monetization` if it is ever activated.
26+
27+
**An unsigned `assembleRelease` path**, with its own 16 KB alignment gate on the release APK.
28+
Signing material is the maintainer's to provision, so a *signed* release build stays out of reach —
29+
but an unsigned one still runs R8, resource shrinking and the release manifest merge, which is
30+
where release-only breakage lives. `isMinifyEnabled` is `false` today, so this currently proves the
31+
release variant assembles; it is wired now because the moment minification is enabled, this is what
32+
catches the fallout.
33+
34+
**An instrumented UniFFI smoke test** (`android/app/src/androidTest`), in its own CI job.
35+
`assembleDebug` already proves the bindings *compile* — `MainActivity` calls `MobileCore` directly.
36+
What no build can prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that
37+
JNA's mapping matches its symbols, and that a call marshals across and returns. This project has
38+
already shipped one native Android crash a build could not have caught. It is a separate job
39+
because an emulator is the flakiest thing in that workflow, and a flaky step inside `build` would
40+
put the 16 KB gates — which are not flaky and do gate a real Play requirement — behind an AVD boot.
41+
42+
**Four entries in the readiness doc's deferred list had gone stale** and are now marked DONE rather
43+
than deleted, because a readiness document that silently drops items cannot be audited backwards:
44+
`android.yml` exists and gates alignment twice, the `./gradlew` wrapper is committed, `ios.yml`
45+
boots a simulator and requires the app to survive the launch, and the §4.7 audit is done. What
46+
remains genuinely outstanding is stated as such — distribution signing, TestFlight, and Play's Data
47+
Safety form, all maintainer-blocked. **Mobile Phase 6 stays NOT GREENLIT**; passing this audit
48+
removes a prerequisite from that gate's checklist, it does not move the gate.
49+
1450
- **`A6.15` — every 65C816 opcode is defined, and only `STP` hangs. Coverage 361 of 443.** The row
1551
executes each of the 241 straight-line opcodes in a WRAM sandbox and counts three outcomes against
1652
the length **Table 5-4 of the WDC W65C816S datasheet** documents: returned where it should,

‎android/app/build.gradle.kts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ android {
1818
targetSdk = 34
1919
versionCode = 1
2020
versionName = "1.18.0"
21+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2122
}
2223

2324
buildTypes {
@@ -47,6 +48,15 @@ android {
4748
getByName("main") {
4849
jniLibs.srcDirs("src/main/jniLibs")
4950
}
51+
// The instrumented UniFFI smoke test needs the same generated bindings the app uses --
52+
// `androidTest` compiles as its own variant and does not inherit `main`'s generated
53+
// sources automatically.
54+
getByName("androidTest") {
55+
kotlin.srcDirs("src/androidTest/kotlin")
56+
// The instrumented smoke test loads a REAL cart, so it needs one packaged with it.
57+
// Copied in by `copyTestRom` below rather than checked in twice.
58+
assets.srcDirs("src/androidTest/assets")
59+
}
5060
}
5161
}
5262

@@ -125,11 +135,33 @@ tasks.register<Exec>("uniffiBindgenMonetization") {
125135
android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi/uniffi")
126136
android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi-monetization/uniffi")
127137

138+
// AccuracySNES's HiROM image (64 KB) as an instrumented-test asset.
139+
//
140+
// This project's own cart, dual-licensed with the repo, so unlike every commercial ROM it can be
141+
// packaged into a test APK. It is what turns the smoke test from "the bindings load" into "a real
142+
// cart boots on a device" -- `docs/mobile-readiness.md` records that no ROM had ever actually
143+
// booted on a device or simulator, and an emulator bridge test with no ROM cannot fix that.
144+
//
145+
// The HiROM variant, not the 256 KB LoROM one, because the test only needs a cart that runs and
146+
// this is the smallest of the four the generator emits.
147+
val copyTestRom = tasks.register<Copy>("copyTestRom") {
148+
from(rootProject.projectDir.parentFile.resolve("tests/roms/AccuracySNES/build")) {
149+
include("accuracysnes-hirom.sfc")
150+
}
151+
into(project.projectDir.resolve("src/androidTest/assets"))
152+
}
153+
128154
tasks.named("preBuild") {
129-
dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization")
155+
dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization", copyTestRom)
130156
}
131157

132158
dependencies {
159+
// The instrumented UniFFI smoke test (`src/androidTest`). It proves the generated bindings
160+
// LOAD and CALL on a device, which a build cannot: `assembleDebug` already proves they
161+
// compile, because `MainActivity` calls `MobileCore` directly.
162+
androidTestImplementation("androidx.test.ext:junit:1.2.1")
163+
androidTestImplementation("androidx.test:runner:1.6.2")
164+
133165
implementation("androidx.core:core-ktx:1.15.0")
134166
implementation("androidx.activity:activity-compose:1.9.3")
135167
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.doublegate.rustysnes
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertFalse
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
import org.junit.runner.RunWith
10+
import uniffi.rustysnes_mobile.MobileCore
11+
import uniffi.rustysnes_mobile.MobileRegion
12+
13+
/**
14+
* The UniFFI smoke test: proves the generated Kotlin bindings actually **load and call** the native
15+
* library on a real Android runtime.
16+
*
17+
* `assembleDebug` already proves the bindings *compile* against the shell — `MainActivity` calls
18+
* `MobileCore` directly, so a bindgen output that drifted from the Rust API fails the Kotlin
19+
* compile. What a build cannot prove is that `System.loadLibrary` finds the `.so` for the device's
20+
* ABI, that JNA's mapping matches the symbols in it, and that a call marshals across and returns.
21+
* Those are runtime facts, and this project has already shipped one native Android crash that a
22+
* build could not have caught.
23+
*
24+
* Deliberately ROM-free. The app takes ROMs only from the user's document picker
25+
* (`docs/app-store-4-7-self-audit.md`), so there is no ROM to open here and no need for one: every
26+
* assertion below is about the *bridge*, not about emulation, which the workspace's own test suite
27+
* covers far better than an emulator can.
28+
*/
29+
@RunWith(AndroidJUnit4::class)
30+
class MobileCoreSmokeTest {
31+
/** Constructing the core loads the library and crosses the FFI boundary once. */
32+
@Test
33+
fun the_native_library_loads_and_a_core_can_be_constructed() {
34+
val core = MobileCore(MobileRegion.NTSC)
35+
assertFalse("a freshly constructed core must report no ROM loaded", core.romLoaded())
36+
}
37+
38+
/**
39+
* A frame with no ROM loaded still has to return a correctly sized framebuffer. This is the
40+
* assertion that would catch a marshalling error: a wrong length, or a returned buffer that
41+
* does not survive the crossing, shows up here and nowhere in a build.
42+
*/
43+
@Test
44+
fun a_frame_runs_and_returns_a_framebuffer_of_the_declared_size() {
45+
val core = MobileCore(MobileRegion.NTSC)
46+
core.runFrame()
47+
48+
val size = core.frameSize()
49+
assertTrue("frame width must be positive, got ${size.width}", size.width > 0u)
50+
assertTrue("frame height must be positive, got ${size.height}", size.height > 0u)
51+
52+
val fb = core.framebuffer()
53+
assertEquals(
54+
"the framebuffer length must be width * height * 4 (RGBA8)",
55+
(size.width * size.height * 4u).toInt(),
56+
fb.size,
57+
)
58+
}
59+
60+
/** AccuracySNES's HiROM image, packaged as a test asset by Gradle's `copyTestRom`. */
61+
private fun testRom(): ByteArray =
62+
InstrumentationRegistry.getInstrumentation().context.assets
63+
.open("accuracysnes-hirom.sfc").use { it.readBytes() }
64+
65+
/**
66+
* A **real cart boots on the device**, and only then is the audio buffer asserted.
67+
*
68+
* This test took two wrong turns, both of the same kind. It first asserted that two successive
69+
* `drainAudio()` calls return equal counts, claiming to pin the documented non-destructive
70+
* contract; then that the buffer length is even, claiming to pin interleaved stereo. A no-ROM
71+
* frame produces **no audio at all** (measured on the host: `first=0 second=0`), so the first
72+
* passed on `0 == 0` and the second on `0 % 2 == 0`. Neither could fail for the reason it named.
73+
*
74+
* The fix is not a better assertion, it is a cart. `docs/mobile-readiness.md` records that no
75+
* ROM had ever actually booted on a device or simulator; loading one here closes that and makes
76+
* every assertion below capable of failing.
77+
*/
78+
@Test
79+
fun a_real_cart_boots_and_produces_audio() {
80+
val core = MobileCore(MobileRegion.NTSC)
81+
core.loadRom(testRom())
82+
assertTrue("the cart did not load", core.romLoaded())
83+
84+
// Eight frames for margin, not because eight are needed: measured on the host, a booted
85+
// AccuracySNES cart emits 1066 interleaved samples from the FIRST frame. Stating that
86+
// rather than a plausible "the APU needs the IPL handshake first" — which the measurement
87+
// disproves — keeps the comment something a reader can rely on.
88+
repeat(8) { core.runFrame() }
89+
90+
val audio = core.drainAudio()
91+
assertTrue("a booted cart produced no audio at all", audio.isNotEmpty())
92+
assertEquals(
93+
"drainAudio must return interleaved stereo, so its length is always even",
94+
0,
95+
audio.size % 2,
96+
)
97+
}
98+
99+
/** Reset and power-cycle are the two lifecycle calls the shell makes; both must cross safely. */
100+
@Test
101+
fun the_lifecycle_calls_cross_the_boundary() {
102+
val core = MobileCore(MobileRegion.NTSC)
103+
core.reset()
104+
core.powerCycle()
105+
assertFalse("no ROM was ever loaded", core.romLoaded())
106+
}
107+
}

0 commit comments

Comments
 (0)