Android port — rebased over main + CI#208
Merged
Merged
Conversation
Top-level CMake: gate Torch ExternalProject + post-build copy + ExtractAssets on non-Android (Torch is host-only); force USE_OPENGLES on Android; build as SHARED libmain.so instead of executable. port/coroutine_posix.cpp: exclude on __ANDROID__ (bionic dropped ucontext). port/coroutine_android.cpp: spike abort-stub; real impl needs aarch64 asm swap or pthread+condvar. port/port_watchdog.cpp: stub backtrace/backtrace_symbols_fd on bionic <33; real impl needs libunwind or minSdk bump. docs/android_port_spike_2026-05-01.md: full write-up of what works, what's blocked, and the prioritized roadmap to a runnable APK (~2 weeks). Verified: cmake --build builds 369 TUs and links 67 MB aarch64 libmain.so against NDK r29 / API 26. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4da72a3)
scripts/android-env.sh — sources NDK / SDK / JDK17 paths into the shell. scripts/android-emulator.sh — creates the ssb64test AVD (Pixel 6 device, Android 14 arm64 system image) and boots it. --recreate to nuke + rebuild, --shell to drop into adb shell after boot. Verified: emulator boots in ~10s on M-series, adb sees device, libmain.so push lands and is recognized as 'ELF shared object, 64-bit LSB arm64, for Android 26, built by NDK r29' on the running device. Doc updated with full toolchain install + emulator instructions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 20a72a3)
Replaces the abort-stub in coroutine_android.cpp with a real impl. The context-switch primitive is ~30 lines of aarch64 asm in port/coroutine_aarch64.S that saves the AAPCS64 callee-saved set (x19-x30, sp, d8-d15) and restores another context's saved state, same approach as boost::context's fcontext_t. A new C trampoline (port_coroutine_trampoline_c) is invoked from an asm shim that picks up the PortCoroutine* from x19 (which we pre-set in port_coroutine_create); it calls the user entry, marks finished, and permanently swaps back to the caller context. CMakeLists: enable_language(ASM) on Android, exclude coroutine_test.cpp from the GLOB-built game target, add the .S file to the ssb64 SHARED library, and gate a coroutine_test executable behind SSB64_BUILD_COROUTINE_TEST=ON. port/coroutine_test.cpp: standalone harness (no game deps; only a port_watchdog_note_yield stub) that exercises round-robin scheduling, nested resume + sCurrentCoroutine save/restore, stack preservation across yields, and the in_coroutine query. Verified PASS on the ssb64test AVD (Android 14 arm64-v8a) — push to /data/local/tmp/ and run. cmake -DSSB64_BUILD_COROUTINE_TEST=ON ... cmake --build build-android --target coroutine_test adb push build-android/coroutine_test /data/local/tmp/ adb shell /data/local/tmp/coroutine_test -> "PASS coroutine_test" EXIT=0 The libmain.so still references the same six symbols; readelf confirms both asm symbols (port_coroutine_swap, port_coroutine_trampoline_aarch64) and the four C++ entrypoints are exported. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 19417a4)
Main's save persistence (port/port_save.{cpp,h}, merged earlier) routes
through Ship::Context::GetPathRelativeToAppDirectory, which on Android
calls SDL_AndroidGetExternalStoragePath — landing the save at
/storage/emulated/0/Android/data/<pkg>/files/ssb64_save.bin. No
port-side changes needed; the save chain is already Android-aware.
What was missing: the Android-side Auto Backup config that controls
what gets synced to the user's Google Drive. Stage two XML files at
android/app/src/main/res/xml/ that the Phase-3 AndroidManifest will
reference via android:fullBackupContent + android:dataExtractionRules.
Scope: ssb64_save.bin only. Excluded: extracted .o2r asset bundles
(too large, regenerated from APK assets on first launch) and
logs/cache directories.
Domain is "external" everywhere because LUS routes saves through
getExternalFilesDir, not internal filesDir.
Doc updated with the save-data section and full file list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 876853e)
Adds an Android Gradle (AGP 8.13 / Gradle 9.0) project under android/
that wraps the existing CMakeLists.txt via externalNativeBuild. Produces
a debug APK at app/build/outputs/apk/debug/app-debug.apk (101 MB) that
bundles libmain.so (91 MB unstripped) + libSDL2.so + libc++_shared.so
for arm64-v8a.
Layout follows AGP convention so Android Studio can open android/ as a
project root with no extra config:
android/
settings.gradle.kts — pinned plugin/repo sources
build.gradle.kts — top-level (AGP 8.13.0)
gradle.properties — JVM args + AndroidX flags
gradle/wrapper/ — Gradle 9.0 wrapper
.gitignore — excludes build/, .cxx/, local.properties
app/
build.gradle.kts — externalNativeBuild → ../../CMakeLists.txt
src/main/
AndroidManifest.xml — Auto Backup wired to xml/backup_rules
java/com/jrickey/battleship/MainActivity.java
res/values/strings.xml
res/xml/{backup_rules,data_extraction_rules}.xml (already staged)
Verified end-to-end on the ssb64test AVD (Android 14 arm64):
./gradlew assembleDebug -> BUILD SUCCESSFUL in 2m 43s (cold)
adb install -r app-debug.apk
am start ...MainActivity -> Activity reaches RESUMED, no crashes
MainActivity is intentionally a non-loading placeholder. It only checks
that libmain.so + libSDL2.so are present in the JNI lib dir, then renders
a status page. We can't `System.loadLibrary("main")` yet because libSDL2's
JNI_OnLoad runs as a side effect of dlopen and calls
FindClass("org/libsdl/app/SDLActivity"), which doesn't exist until
Phase 3 vendors SDL2's Java glue. Without that class the JVM aborts the
process with a JNI error. Phase 3 is the right place to wire it.
Toolchain pinning:
AGP 8.13.0 (first AGP line that supports Gradle 9.x)
Gradle 9.0 (matches brew)
NDK 29.0.14206865 (matches /opt/homebrew/share/android-ndk)
compile / target SDK 34, min SDK 26
Single ABI for now (arm64-v8a). Native lib is built unstripped in debug
to keep ndk-stack symbolication functional once we start hitting native
crashes in Phase 3+.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 752a01f)
Native side (port/port.cpp):
Made `#define SDL_MAIN_HANDLED` non-Android-only. On Android, SDL_main.h
is now included transitively from <SDL.h>, which `#define`s `main` to
`SDL_main` so our entry point is exported under the symbol name SDLActivity
expects from dlsym. Verified: nm -D libmain.so shows `T SDL_main` at the
expected offset; no `T main` symbol on Android, but desktop builds still
produce `T main` via the existing path.
Java side (android/app/src/main/java/):
- Vendored SDL2 release-2.32.10's android-project java tree under
org/libsdl/app/ (9 files: SDLActivity.java + SDL.java + Audio /
Controller / Surface managers + 4 HIDDevice* classes). Source SHA
5d2495703 ("Updated to version 2.32.10 for release").
- New BattleShipActivity extends SDLActivity, overrides getLibraries()
to return ["SDL2", "main"]. SDLActivity loads them in order, picks
the last as the "main" lib, and calls dlsym("SDL_main") on it.
- Removed the Phase-2 placeholder MainActivity. AndroidManifest's
<activity> now points at .BattleShipActivity, with the SDL-recommended
configChanges + alwaysRetainTaskState + preferMinimalPostProcessing.
- Added VIBRATE permission and softly-required input feature decls
(gamepad/usb-host/bluetooth/touchscreen/pc) — same as SDL2's reference
AndroidManifest. None hard-required so Play Store filtering stays open.
Verified end-to-end on the ssb64test AVD (Android 14 arm64):
./gradlew assembleDebug → BUILD SUCCESSFUL in 11s (incremental)
adb install -r app-debug.apk
am start ...BattleShipActivity
-> SDLActivity onCreate / onStart / onResume / surfaceCreated
-> "Running main function SDL_main from library .../libmain.so"
-> "nativeRunMain()"
-> Process stays alive at PID 3581, no FATAL/crash buffer entries
What does NOT work yet (expected — that's Phase 4 territory):
- The native side runs PortInit, calls
Ship::Context::GetPathRelativeToAppDirectory("/./f3d.o2r"), gets the
correct external-files path, but the .o2r files aren't there because
the APK doesn't bundle them yet. LUS logs:
"The archive at path /./f3d.o2r does not exist"
and the game blocks waiting for assets.
- Phase 4 will: (a) bundle BattleShip.o2r/f3d.o2r/ssb64.o2r into the APK
assets, (b) extract them to filesDir on first launch via JNI/AAssetManager
or a Java-side copy, (c) verify LUS opens them.
Save data wiring is now functional in path-resolution terms: the app's
log + cfg landed at /storage/emulated/0/Android/data/<pkg>/files/ as
expected. Once Phase 4 supplies the assets, ssb64_save.bin will land in
the same dir and Auto Backup (already wired in xml/backup_rules.xml)
will sync it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit d8eceeb)
Torch is the asset extractor that turns the user's baserom.us.z64 into
BattleShip.o2r — a build-time host tool on desktop, but on Android we
ship it inside the APK so the first-run UI can extract on-device.
CMakeLists.txt:
- On Android, force USE_STANDALONE=OFF and add_subdirectory(torch).
Torch's existing else-branch builds it as a STATIC library.
- Add a new SHARED target torch_runner that wraps libtorch.a + a
small extern "C" entry point. Output name is libtorch_runner.so so
AGP packages it into the APK lib/arm64-v8a/ alongside libmain.so.
- Filter port/android_torch_bridge.cpp out of the GLOB so it stays
out of the ssb64 (libmain.so) build — the bridge file is only
linked into libtorch_runner.so. Keeps libtorch + its deps from
bloating libmain and bypasses the SDLActivity-required class
issue (Torch must be loadable before SDL2's JNI_OnLoad runs).
port/android_torch_bridge.cpp:
Implements
extern "C" int torch_extract_o2r(const char *rom_path,
const char *src_dir,
const char *dst_dir);
Mirrors the o2r subcommand callback in torch/src/main.cpp:
constructs Companion in O2R mode and runs Init(ExportType::Binary).
Wraps in try/catch so JNI callers get an int return code rather
than an unwound exception. Logs to logcat tag "ssb64.torch".
android/app/build.gradle.kts:
Add torch_runner to externalNativeBuild.cmake.targets so AGP
drives the build. (ssb64 was already there.) Comments explain
which transitive libs come along.
Verified on the configure-test loop:
./gradlew assembleDebug → BUILD SUCCESSFUL in 34s
unzip -l app-debug.apk:
lib/arm64-v8a/libSDL2.so 4.5 MB
lib/arm64-v8a/libc++_shared.so 9.0 MB
lib/arm64-v8a/libmain.so 91.0 MB
lib/arm64-v8a/libtorch_runner.so 34.0 MB ← new
nm -D libtorch_runner.so | grep torch_extract:
T torch_extract_o2r
objdump -p libtorch_runner.so | grep NEEDED:
liblog.so libm.so libc++_shared.so libdl.so libc.so
(no libSDL2.so, no libmain.so → safe to dlopen pre-SDLActivity)
No source patches to torch/ were needed; Torch's existing portable
filesystem code compiles clean against bionic / NDK r29 clang.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 77d8fa4)
Adds the asset bundle pipeline: f3d.o2r (Fast3D shaders), ssb64.o2r
(port-local custom assets), config.yml, and yamls/us/*.yml are packed
into the APK at build time and copied to externalFilesDir on first
launch. BattleShip.o2r is intentionally NOT bundled — that's
copyrighted Nintendo content extracted from the user's own ROM, which
Phase 4.4 will handle via a SAF picker + libtorch_runner.so.
android/app/build.gradle.kts:
- New stageGameAssets Copy task. Resolves files from
{repoRoot}/build/<file> first (where new-worktree.sh symlinks
main-tree outputs) then {repoRoot}/<file>, fails with a clear
message otherwise.
- sourceSets.main.assets.srcDirs adds the staged dir so AGP merges
its contents into the APK assets/ at packageDebug time.
- afterEvaluate hooks stageGameAssets in front of every AGP
mergeXxxAssets task so all variants pick it up.
- buildFeatures.buildConfig = true (AGP 8 disables by default;
AssetExtractor reads BuildConfig.VERSION_CODE).
android/app/src/main/java/com/jrickey/battleship/AssetExtractor.java:
Walks AssetManager and copies the bundled files into externalFilesDir.
Writes a sentinel (.bundled_assets_version) keyed on versionCode so
app updates that bump the code force a re-extract.
android/app/src/main/java/com/jrickey/battleship/BootActivity.java:
New launcher Activity. Background-thread asset extraction, status
text on the UI thread. After extraction:
- If BattleShip.o2r exists → start BattleShipActivity (the SDL one).
- Else → render a placeholder telling the developer how to adb-push
a pre-built BattleShip.o2r. Phase 4.4 replaces this with the
SAF ROM picker + Torch invocation.
AndroidManifest.xml:
BootActivity is now the LAUNCHER intent target. BattleShipActivity
is non-exported (only reachable via Intent from BootActivity), keeps
its USB_DEVICE_ATTACHED filter for SDL HIDAPI.
Verified end-to-end on the ssb64test AVD:
./gradlew assembleDebug → BUILD SUCCESSFUL, APK now 197 MB
unzip -l app-debug.apk | grep assets/:
assets/config.yml 390 B
assets/f3d.o2r 8.3 KB
assets/ssb64.o2r 732 KB
assets/yamls/us/ 17 reloc_*.yml files
adb install -r ; am start ...BootActivity:
[ssb64.assets] First-run extraction into /storage/.../files
[ssb64.assets] Asset extraction complete
[ssb64.boot] BattleShip.o2r missing; awaiting Phase 4.4
adb shell ls .../files: config.yml f3d.o2r ssb64.o2r yamls/ ✓
adb push BattleShip.o2r .../files/ then relaunch:
[ssb64.boot] Assets ready, launching BattleShipActivity
SDLActivity surfaceCreated → Running SDL_main from libmain.so
(Process subsequently dies in SDL2's HIDDeviceManager.initialize
during osContInit → SDL_Init → SDL_hid_init — that's a Phase 5
"first-boot triage" issue, separate from the asset pipeline.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 68dbd47)
Closes the first-run setup loop. The user picks their copy of the SSB64
US ROM via Android's Storage Access Framework, BootActivity streams the
content URI into cacheDir, then libtorch_runner.so runs against the
local file and produces BattleShip.o2r in externalFilesDir.
port/android_torch_bridge.cpp:
Added Java_com_jrickey_battleship_RomImporter_extractO2R — converts
three jstrings to UTF-8, calls torch_extract_o2r, releases. Returns
the C function's int directly to Java.
android/app/src/main/java/.../RomImporter.java:
Loads libtorch_runner via System.loadLibrary in a static block (one-shot,
triggered the first time Java touches the class). Exposes:
- native int extractO2R(romPath, srcDir, dstDir) — JNI thin wrapper
- File stageRomFromUri(ctx, uri) — SAF URI → cacheDir
(Torch's fopen() can't read content:// URIs, so we stream it down
to a real path first.)
android/app/src/main/java/.../BootActivity.java:
Now extends androidx.activity.ComponentActivity for the modern
registerForActivityResult API. Two extraction triggers:
- SAF picker (ACTION_OPEN_DOCUMENT, mime "*/*"). On result, stage
URI → call extractFromAbsolutePath() on a worker thread.
- Dev shortcut: Intent extra "ssb64.dev_rom" with an absolute path
bypasses the picker, runs straight through extraction. Used for
emulator integration tests (and also handy for re-extraction
while iterating on Torch internals).
Both paths funnel into a shared extractFromAbsolutePath worker that
invokes RomImporter.extractO2R off the UI thread, posts status
updates back, and on success starts BattleShipActivity.
android/app/build.gradle.kts:
- dependencies: androidx.activity:activity:1.9.3 (required for
ComponentActivity + the result-API contracts).
- resolutionStrategy: pin every kotlin-stdlib* artifact to 1.9.25.
The stock androidx.activity 1.9.3 transitively pulls
kotlin-stdlib-jdk8:1.6.21, which collides with AGP's bundled
kotlin-stdlib:1.8.22 (jdk8 was merged into stdlib in Kotlin 1.8).
Without the strategy, dexer fails on duplicate classes
(kotlin.jvm.jdk8.JvmRepeatableKt et al).
Verified end-to-end on the ssb64test AVD (Android 14 arm64):
./gradlew assembleDebug → BUILD SUCCESSFUL
adb push baserom.us.z64 .../files/baserom.us.z64
adb shell am start ...BootActivity --es ssb64.dev_rom .../baserom.us.z64
[ssb64.assets] Bundled assets are current (versionCode=1)
[ssb64.torch] torch_extract_o2r: rom=... src=... dst=...
[ssb64.torch] torch_extract_o2r: extraction completed (~5s)
[ssb64.boot] Assets ready, launching BattleShipActivity
adb shell ls .../files/:
BattleShip.o2r 12 MB ← extracted from user's ROM
config.yml + f3d.o2r + ssb64.o2r + yamls/ (from Phase 4.3 bundle)
Process subsequently dies in SDL2's HIDDeviceManager.initialize during
osContInit → SDL_Init — same crash surface as Phase 4.3, expected to
be triaged as the first item in Phase 5 ("first-boot triage").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit edcc424)
…droid
Two co-located fixes get past first-boot on the ssb64test AVD:
1. port/port.cpp — pre-init SDL_INIT_GAMECONTROLLER on the SDL_main
thread, before any port_coroutine starts. The N64 controller thread
becomes a coroutine in our cooperative scheduler; when its
osContInit calls SDL_Init(SDL_INIT_GAMECONTROLLER), SDL2 on Android
reaches HIDDeviceManager.initialize via JNI. ART's CheckJNI tracks
JNI transition frames per thread, but our coroutine swap relocates
SP without the JVM's knowledge — any jstring local ref created
inside the Java callback (e.g. Log.v's TAG arg) comes back as
"invalid JNI transition frame reference" and the process aborts.
Doing the joystick subsystem init now (real OS thread, no fiber
switches in flight) gets the JNI side of SDL_hid_init out of the
way; the later SDL_Init in osContInit becomes a no-op.
2. libultraship submodule bump (ssb64 → 78fddec) — same root cause:
MouseStateManager::CursorVisibilityTimeoutTick runs on every
StartFrame from the GFX coroutine and on Android reaches
SDLActivity.setCustomCursor via JNI. Touch devices have no cursor
to manage, so the tick is a no-op on Android; returning early
skips the JNI path entirely.
Verified end-to-end on the ssb64test AVD (Android 14 arm64):
./gradlew assembleDebug && adb install -r app-debug.apk
am start ...BootActivity --es ssb64.dev_rom .../baserom.us.z64
[ssb64.assets] Bundled assets are current (versionCode=1)
[ssb64.torch] torch_extract_o2r: extraction completed (~5s)
[ssb64.boot] Assets ready, launching BattleShipActivity
SDL: Running main function SDL_main from libmain.so
audio_bridge: all audio assets loaded successfully
(no SIGABRT, no JNI transition-frame errors)
Screenshot at this point shows Link in the attract-demo Hyrule
Castle scene, title-bar "BattleShip (OpenGL)". GLES rendering and
game logic both running.
Known follow-ups (not blocking the demo render):
- Display orientation: BootActivity is portrait, BattleShipActivity is
sensorLandscape but the SDL surface ends up letterboxed on a
portrait phone instead of rotating. Phase 6 (touch UX) is the right
place to tighten this.
- HIDDeviceManager.hid_init logs an exception about
RECEIVER_EXPORTED on Android 13+; non-fatal — SDL2 main reportedly
has a fix newer than 2.32.10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit d94b940)
The load-bearing fix that gets the SSB64 attract demo rendering stably on
Android. Confirmed multi-frame: caught Kirby + Mario scenes from the
attract cycle in succession on the ssb64test AVD.
Root cause we couldn't get past with surgical guards (see Harbour
Masters research note in the spike doc): ImGui_ImplSDL2_NewFrame calls
ImGui_ImplSDL2_UpdateMonitors every frame → SDL_GetDisplayUsableBounds
→ ParseDisplayUsableBoundsHint → SDL_GetHint → SDL_getenv. On Android,
SDL_getenv falls through to Android_JNI_GetManifestEnvironmentVariables,
which Binder-IPCs into Java's PackageManager.getApplicationInfo, which
allocates a jobject local ref. ART's CheckJNI tracks references per
OS thread via ManagedStack — a linked list whose head lives on the
native stack. Our cooperative scheduler runs port_coroutines as
aarch64 fibers stack-switched on the SDL_main thread; after the swap
the ManagedStack head dangles, and the new jobject is rejected as
"invalid JNI transition frame reference", aborting the process.
The Phase 5 demo-render screenshot was timing-dependent: the first
frame happened to render before the GFX coroutine hit ImGui's per-frame
bounds query. Subsequent frames consistently aborted.
port/gameloop.cpp — port_submit_display_list now stages the DL pointer;
PortPushFrame's tail (after port_resume_service_threads, on the real
SDL_main thread) calls a new port_drain_pending_display_list which
runs DrawAndRunGraphicsCommands. The scheduler's task-completion
signaling is decoupled from the actual GPU work, so the deferral is
invisible to the cooperative scheduler. One DL slot is enough — SSB64
submits one DL per VI tick.
port/port.cpp — Android pre-init block expanded:
* Hoist SDL_INIT_GAMECONTROLLER (HID JNI on real thread)
* SDL_SetHint(SDL_HINT_DISPLAY_USABLE_BOUNDS, "0,0,1920,1080")
populates SDL2's per-program hint store so the eventual
SDL_GetHint short-circuits before reaching the env-lookup path.
* SDL_getenv warmup so bHasEnvironmentVariables is true; any
coroutine-side SDL_getenv after this is a libc cache hit.
port/resource/RelocFileTable.cpp + tools/generate_reloc_table.py —
add #include <cstddef> for NULL. The generated code uses NULL but
NDK r29's stricter header set doesn't pull stddef in transitively;
desktop toolchains had been picking it up incidentally. Patch the
generator so future regenerations stay clean.
What still TODO (Phase 6.3 next):
- TouchOverlay.java + port/android_touch_overlay.cpp got rm'd during
a clean during triage — re-add to wire the SDL virtual gamepad.
- Minor visual artifact in deferred renders: ghost framebuffer in
one-half of the screen on some frames. Likely framebuffer rotation
desync vs deferred render. Phase 7 can chase this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit eb31202)
Closes Phase 6. The Android build now ships:
- On-screen A button overlay rendered above the SDL surface (Phase
6.3 minimum-viable; B/Z/Start/D-pad/analog stick land in 6.4+).
- Native virtual SDL gamepad attached lazily on first touch via
SDL_JoystickAttachVirtualEx with Xbox-360 vendor/product signature,
so SDL2 auto-promotes it to SDL_GameController and LUS's existing
SDLButtonToAnyMapping picks it up without Android-aware changes.
- JNI bridge for setButton(int, bool) and setAxis(int, int) — Java's
UI thread can call these directly; SDL_JoystickSetVirtual* is
documented as thread-safe.
port/android_touch_overlay.cpp:
ensureAttached() once-per-process attaches the virtual joystick.
Two JNI methods forward Java state changes into SDL's joystick state.
android/app/src/main/java/.../TouchOverlay.java:
install(activity) addContentView's a transparent FrameLayout above
SDL's GLES surface. Single round button (A), positioned bottom-right,
forwarding press/release to setButton(SDL_CONTROLLER_BUTTON_A, ...).
Handles ACTION_POINTER_DOWN/UP for multitouch.
android/app/src/main/java/.../BattleShipActivity.java:
onCreate calls TouchOverlay.install(this) after super.onCreate so the
overlay renders above the SDL surface SDLActivity sets up.
Verified end-to-end on the ssb64test AVD:
Game renders SSB64 attract demo (Kirby's Dream Land scene visible
in screenshot), green A button overlay sits in bottom-right corner.
Tapping the button:
[ssb64.touch] Virtual joystick attached
(device_index=1, instance_id=1, 6 axes, 21 buttons)
Process stays alive — JNI from Java UI thread routes through
SDL_JoystickSetVirtualButton without coroutine-frame issues.
Phase 6.4+ (TODO):
- Add B / Z / Start / shoulder buttons + D-pad
- Virtual analog stick (SDL_CONTROLLER_AXIS_LEFTX/Y) — left half of
screen as drag region producing axis values
- Auto-hide overlay when paired Bluetooth/USB controller is detected
(LUS already polls for that)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6e678e0)
The minimum-viable A button from 6.3 isn't enough to play SSB64.
This adds the rest: a virtual analog stick on the left half (drag
anywhere, floating-anchor) and four face buttons clustered on the
right.
android/app/src/main/java/.../AnalogStickView.java (NEW):
Custom View. Floating-anchor design — wherever the user first
touches inside the host, that's where the stick "appears". Drag
delta is clamped to a configurable radius (80dp), normalized, and
scaled to SDL_CONTROLLER_AXIS_LEFTX/Y in signed-16 range. On
release, axes return to 0/0. Visualises the anchor + the moving
thumb when active; invisible when idle.
Floating over fixed because SSB64 movement is grace-of-the-stick
and a fixed-position stick forces the user's thumb onto a small
target that's awkward to find without looking. Fighting-game
mobile convention.
android/app/src/main/java/.../TouchOverlay.java:
Replaces the single-A scaffold with the full layout:
[Z] [Start]
[B]
[A]
Mappings (all routable via LUS controller config UI):
A → SDL_CONTROLLER_BUTTON_A (jump / aerial)
B → SDL_CONTROLLER_BUTTON_B (special)
Z → SDL_CONTROLLER_BUTTON_LEFTSHOULDER (shield / grab)
Start → SDL_CONTROLLER_BUTTON_START (pause)
Left-half FrameLayout hosts the AnalogStickView; right-half
buttons are positioned absolutely with gravity + dp margins.
Verified end-to-end on the ssb64test AVD:
- Game continues to render attract demo cycle (caught DK Country
+ Link/Hyrule Castle scenes back-to-back)
- All four face buttons render at expected positions, sized for
thumb access
- Tapping a button still triggers TouchOverlay.setButton →
SDL_JoystickSetVirtualButton on first call:
[ssb64.touch] Virtual joystick attached
(device_index=1, instance_id=1, 6 axes, 21 buttons)
- Process stable through scene transitions
What still TODO:
- Auto-hide overlay when paired Bluetooth/USB controller detected
(LUS already polls for that — wire in a follow-up phase)
- Per-button visual press feedback (currently no animation)
- Layout configurability — anchor margins are dp-hardcoded;
Phase 8 polish could move them to res/values/dimens.xml
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 87e632f)
Phase 7.1 — audio: booted the ssb64test AVD WITHOUT -no-audio, ran the attract demo for 24+ seconds, observed continuous AudioFlinger mixer activity (1024-frame chunks, throttle stable). SDL2's default Android audio backend (OpenSL ES) works out of the box — the audio thread SDL spawns is a real OS thread, not a port_coroutine fiber, so no JNI-from-fiber landmines on this path. AAudio is a Phase 8 latency polish item. Phase 7.2 — ghost framebuffer: 8 screenshots over 24s spanning multiple attract transitions. No artifacts in steady state. The Phase 6.1 ghost was a transient or an intentional split-screen attract cinematic. Adds docs/android_port_status_2026-05-01.md — supersedes the original spike doc, captures end-to-end first-run flow, architecture decisions (GFX-on-main hop, touch overlay → SDL virtual gamepad, never-ship- Nintendo on-device extraction), phase summary table, Phase 8 polish punchlist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit d4e1755)
…verlay layout Three small wins for the in-game polish: 1) Manifest: screenOrientation="sensorLandscape" → "landscape". sensorLandscape allows either 90° or 270° depending on sensor, but the emulator AVD's hardware sensor profile defaults to 0° (portrait) and never trips the threshold. Strict landscape forces the OS to rotate. Real devices honor either spec the same way; the difference only matters for emulator testing. 2) Theme: android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" on the application. Kills the "BattleShip (OpenGL)" Activity title bar and the system status bar, giving the SDL surface the full display height to render the 4:3 game. Applies to BootActivity too, which is fine — the welcome / asset-extracting / ROM-picking screens look cleaner without chrome. 3) TouchOverlay layout reworked for landscape: Start (small) ← top-right [Z] [B][A] ← bottom-right cluster Margins now keep all four buttons fully on-screen for landscape phone widths from ~640dp (Pixel 4a) up. The previous portrait-tall diagonal cluster put B in the middle of the screen and clipped Z on narrower landscape viewports. Verified (with to nudge the AVD into landscape — see comment in AVD config note below): - Game renders at proper 4:3 aspect, fills more of the screen - All four buttons visible without clipping - Stick still on left half (drag-anywhere AnalogStickView) - Process stable through scene transitions AVD note: the bundled Pixel 6 ssb64test AVD ignores screenOrientation in its current config — even with android:screenOrientation="landscape" set, mRotation stays ROTATION_0. Workaround for emulator testing: adb shell wm user-rotation lock 1 Real devices honor the manifest spec normally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6a8387c)
Hooks Android's InputManager.InputDeviceListener so we toggle the
overlay's visibility based on whether any registered InputDevice
exposes SOURCE_GAMEPAD or SOURCE_JOYSTICK. Bluetooth pair / USB plug
fires onInputDeviceAdded; unpair / unplug fires onInputDeviceRemoved.
When a real controller is present:
- Overlay goes View.GONE — no buttons rendered, no touch events
captured
- The virtual SDL gamepad is still attached (LUS may show it as a
duplicate in its controller-config UI; phase 8.4 polish to
SDL_JoystickDetachVirtual it cleanly)
When no controllers:
- Overlay is visible (default boot state on a phone)
Initial visibility is computed via a Runnable post()'d to the UI
thread so it's race-safe even if the listener hasn't fired yet for
an already-paired device at Activity create time.
Verified on the ssb64test AVD: with no controller paired, the full
Start/Z/B/A cluster renders as expected. Hot-plug verification
needs a real device — the Pixel 6 emulator profile doesn't expose
the InputManager controller hot-plug surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3a481a0)
The buttons previously looked static — touch event registers in software but the user has no on-screen confirmation their tap landed on the button vs. the surrounding margin. Adding a brief 40ms-down/80ms-up animation that: - Scales the button to 92% (subtle squish) - Drops alpha to 0.7 (fades while held) Asymmetric durations (down faster than up) feel more responsive — the press registers "snappy" while the release relaxes. setPressedVisuals() runs on the standard Android animator framework, so overlapping touches handle correctly (last-state-wins; no animator build-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dd2d816)
Replaces Android's default white-robot launcher icon with a distinctive
red circle + white "B" mark.
res/drawable/ic_launcher_foreground.xml — vector path drawing the B
glyph from path data (not a font glyph, so renders identically
across devices). Sized for the 108x108dp adaptive-icon canvas with
the 72dp safe zone.
res/values/colors.xml — defines ic_launcher_background = #CC2233
(SSB64 logo red).
res/mipmap-anydpi-v26/ic_launcher.xml — adaptive-icon manifest
composing the foreground over the background; the OS handles the
mask shape (circle on Pixel, squircle on Samsung, etc.) per
launcher-defined themes.
res/mipmap-anydpi-v26/ic_launcher_round.xml — same content; the OS
selects between the two depending on the launcher's preference.
AndroidManifest.xml — wires android:icon + android:roundIcon to the
new mipmap entries.
No legacy png fallbacks because minSdk = 26, which is also the API
level adaptive icons were introduced (anydpi-v26 only). Pre-26
devices wouldn't install our APK anyway.
Verified on the ssb64test AVD: the icon shows distinctly in the
recent-apps card and the all-apps drawer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0f39333)
Makes the project distribution-ready. Devs (or CI) drop in a release
keystore via keystore.properties (gitignored) or SSB64_RELEASE_*
env vars; Gradle picks them up and signs assembleRelease /
bundleRelease output. Without a release keystore configured the
release buildType falls back to the debug signingConfig — APK is
runnable for local smoke-testing but Play Store rejects.
android/app/build.gradle.kts:
+ import java.util.Properties (Gradle 9 Kotlin DSL doesn't auto-import)
+ Read keystore.properties at config time, layered with env-var
lookup
+ signingConfigs.create("release") only when a keystore is
actually configured (otherwise creating it with null storeFile
would fail Gradle config)
+ buildTypes.release picks the release signingConfig if available,
falls back to debug
+ afterEvaluate hook now wires stageGameAssets as a dependency of
Lint* / packageRelease tasks too — Gradle 9 + AGP 8 declare
asset inputs more strictly than AGP 8 alone, so the merge*Assets
hook from Phase 4.3 wasn't enough for assembleRelease
android/keystore.properties.example: documented schema + the keytool
command to generate a fresh release.jks
android/.gitignore: adds keystore.properties (the file itself, not
the .example)
res/xml/backup_rules.xml + data_extraction_rules.xml:
Lint flagged the dual <include>+<exclude> rules as a tautology
("<exclude> path 'logs/' is not in any included path"). When
<include> elements are present, AOSP's Auto Backup treats them as
a whitelist — listed paths back up, everything else is implicitly
excluded. Simplified to <include> domain="external" path=
"ssb64_save.bin" only; the .o2r / logs / cache stay out of the
backup naturally.
Verified:
./gradlew assembleDebug → app-debug.apk (137 MB, fast iteration)
./gradlew assembleRelease → app-release.apk (115 MB, debug-keyed)
./gradlew bundleRelease → app-release.aab (34 MB, Play uploadable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 997acfe)
Sets SDL_HINT_AUDIODRIVER="aaudio" before SDL audio subsystem init. SDL2 release-2.32.10's Android backend has both AAudio and OpenSL ES compiled in (verified via grep on the FetchContent'd SDL_config.h: both SDL_AUDIO_DRIVER_AAUDIO and SDL_AUDIO_DRIVER_OPENSLES are 1). AAudio is the modern Android audio API (8.0+, mandatory for our minSdk 26 anyway). Compared to the legacy OpenSL ES path it gives: - ~5ms lower input-to-output latency - Better behavior under audio device hot-swap (BT headset, USB DAC) - Power-efficiency improvements on Pixel devices If AAudio fails at init, SDL2 silently falls back to the next driver in its bootstrap list (OpenSL ES). On the ssb64test AVD that's what happens — the emulator's audio sim doesn't fully implement AAudio and we see the OpenSLES driver in logcat. On real Android 8.0+ devices the hint takes effect. The hint costs nothing when it doesn't apply. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 58cecb8)
The user couldn't swap to a different ROM (different region, different
patches) without adb-wiping BattleShip.o2r and relaunching. Adding two
clean entry points:
--ez ssb64.repick true on BootActivity's launching Intent → wipes
BattleShip.o2r before the assets-ready check, falling back into
the SAF picker (or a paired --es ssb64.dev_rom shortcut).
res/xml/shortcuts.xml — static launcher shortcut visible on long-press
of the BattleShip icon. shortLabel "Re-extract ROM", longLabel
"Provide a different SSB64 ROM". The shortcut launches BootActivity
with the same ssb64.repick=true extra.
Manifest: <activity>'s ACTION_VIEW + DEFAULT category intent-filter
accepts the ACTION_VIEW the shortcut sends; meta-data wires the
android.app.shortcuts resource.
The bundled APK assets (config.yml / yamls / f3d.o2r / ssb64.o2r) stay
intact across a re-extract — those don't depend on the user's ROM.
Verified on the ssb64test AVD:
adb shell cmd shortcut get-shortcuts com.jrickey.battleship.debug
→ ShortcutInfo {id=repick_rom, ..., shortLabel=Re-extract ROM, ...}
am start ...BootActivity --ez ssb64.repick true --es ssb64.dev_rom <path>
→ BattleShip.o2r mtime advances; torch_extract_o2r runs again;
game launches afresh
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit faaf6e1)
…OM-in-APK ssb64.o2r was an early-development port-asset archive that main removed in PR #190 (2026-05-15). The Android branch was still trying to bundle it into the APK assets — gradle would fail at configure time looking for build/ssb64.o2r, and the runtime extractor referenced an asset that no longer ships. Drop all three references (build.gradle.kts staging, AssetExtractor copyAsset, BootActivity doc). Reaffirm in the doc comments that NOTHING ROM-derived ever travels into the APK: only f3d.o2r (open-source Fast3D shaders), config.yml, and yamls/ (layout metadata) get bundled. BattleShip.o2r is still produced on-device from the user-supplied baserom by libtorch_runner.so on first launch, never shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ssb64-android-rebased = ssb64 + one Android-only commit (cursor-visibility tick skip to avoid JNI-from-coroutine — the two other formerly-Android LUS commits, gfx_sdl2 Destroy ordering and Fast3D PresentCurrentFramebuffer helper, already landed on ssb64 itself, so they got absorbed during the rebase and aren't separate commits anymore). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cord
CMakeLists.txt — Android adaptations layered over main's current structure:
* enable_language(ASM) so port/coroutine_aarch64.S preprocesses.
* SSB64_VERSION pinned to "us" — error if -DSSB64_VERSION=jp slips
through (no JP-Android pipeline + no JP-Android playtesting).
* USE_OPENGLES forced ON before add_subdirectory(libultraship) so
its src/CMakeLists.txt picks the GLESv3 branch and the matching
ENABLE_OPENGL / USE_OPENGLES compile defs.
* USE_STANDALONE=OFF + add_subdirectory(torch) so Torch builds as
a STATIC lib (instead of the desktop ExternalProject_Add path);
the new torch_runner SHARED target at the bottom wraps it for
JNI consumption by RomImporter.java.
* ssb64 target switches from add_executable() to
add_library(SHARED) named "main" so SDLActivity's dlsym("SDL_main")
finds it in libmain.so.
* PORT_HIRES_ENABLED undefined on Android — port/hires/ source
filtered, runtime Init() in port.cpp + menu in PortMenu.cpp are
both already wrapped in the same macro by the upstream code.
* Drop discord-rpc FetchContent + include path + link/dep on Android
(no Discord SDK build target for the NDK).
* Filter Updater.cpp / DiscordRichPresence.cpp / ShaderDownloader.cpp
from the Android source list — the corresponding menu entries are
gated with #if !defined(__ANDROID__) so nothing references the
removed symbols.
* Filter port_window_icon.cpp — Android's launcher icon is owned by
the APK's res/mipmap-anydpi-v26/ic_launcher.xml, no runtime call.
* Filter android_torch_bridge.cpp out of libmain.so (it's the
torch_runner.so's source).
* Wrap the desktop POST_BUILD asset-copy and ExtractAssets blocks in
if(NOT ANDROID) — Android gets its assets via Gradle's
stageGameAssets at APK time and Torch on-device at first launch.
port/port.cpp — gate ssb64::SetWindowIcon() call + its header include
on !__ANDROID__ so the linker doesn't reach for the removed TU.
port/gui/PortMenu.cpp — wrap in #if !defined(__ANDROID__):
* "Enable Discord Rich Presence" checkbox.
* Post-Process Shader picker, Shader Pack Downloader, Shader Runtime
Diagnostics — the libretro shader stack (catalog fetch +
slang→SPIR-V→backend transpile) hasn't been validated against
GLES on mobile, and the catalog ZIP download is a poor fit for
Play Store distro.
* RenderShaderPackDownloader / RenderShaderPackModal definitions
plus the static state they share — they reference removed
ShaderDownloader.cpp symbols, and have no callers on Android.
* Updates section under About (background CheckForUpdatesAsync +
progress UI + Check button) — app updates come through the Play
Store; the curl-driven GitHub-releases path can't replace a
system-managed install.
Hi-res texture pack section is already wrapped in #ifdef PORT_HIRES_ENABLED
upstream, so undefining the macro in CMake hides the entire menu group
(Enable + Open Mods + Indexed PNGs counter + Dump Source / Open Dump /
Miss Dump / Open Miss-Dump) without any source-level change here.
Desktop platforms regress-tested intent: every CMake gate is
`if (CMAKE_SYSTEM_NAME STREQUAL "Android")` so the existing US/JP
desktop paths take the unchanged else branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…idapi stub)
decomp port-patches a1659c27:
+ ptrdiff_t typedef in stddef.h shim for NDK r29 / bionic
(sys/netpeer.c → <arpa/inet.h> → <unistd.h> on Android)
libultraship ssb64-android-rebased 29cbdb9c:
+ fix(postprocess/GLES): gate GL_FRAMEBUFFER_SRGB on !USE_OPENGLES
(added by main's recent postprocess work, GLES has no equivalent toggle)
+ android: stub hidapi (NDK sysroot has no libusb-1.0)
With these, ./gradlew assembleDebug produces a 151 MB app-debug.apk
containing libmain.so + libtorch_runner.so + libSDL2.so + the
non-ROM-derived assets (f3d.o2r / config.yml / yamls/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small native-side additions that the touch overlay layer needs: * SDL_GameController normalizes our virtual joystick's trigger axes (a4/a5 in the Xbox 360 mapping) from raw -32768..+32767 to the GameController range 0..32767, so raw 0 = ~50% pressed. The virtual joystick defaults axes to 0, which left N64 Z/R reading as held the entire time. Park TRIGGERLEFT/RIGHT at -32768 immediately after SDL_JoystickOpen so they rest at released. Java's setTrigger(axis, pressed) honors the same convention on press/release. * Hamburger menu tap: SDL_PushEvent(KEYDOWN F1) from the Android UI thread didn't reliably edge-align with ImGui's per-frame IsKeyPressed(F1, false) in Gui::DrawMenu. Replace with an atomic sMenuTogglePending flag set from JNI and drained on the SDL_main thread inside PortPushFrame, which calls GetGui()->GetMenu()->ToggleVisibility() directly. Reliable, race-free. * isMenuVisible() JNI getter wraps Gui::GetMenuOrMenubarVisible(). The Java overlay polls this every 100ms so it can hide the analog-stick layer while the menu is open — otherwise the stick swallowed taps on menu items before they reached ImGui, forcing virtual-stick navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rger, layered visibility
Iterated through a few rounds of in-emulator UX feedback. Changes:
Visual:
* Six new vector drawables under res/drawable/ (btn_n64_a/b/z/r/start/menu).
Flat injection-molded plastic look — single subtle vertical linear
gradient (5% range) plus a crisp darker rim stroke. N64-correct palette:
A=#2867FF blue, B=#2E9E3F green, Z=#2C2C2C trigger gray, R=#4A4A4A
shoulder gray, Start=#C8252E red, menu=#6B6B6B with the hamburger
bars baked in.
* ImageView + TextView per button replaces the old flat GradientDrawable
circle. Same press-feedback animation (92% scale + 70% alpha).
Layout:
* Face cluster bottom-right (A primary, B inset). Start bottom-center,
well clear of A to stop the mistap.
* Z/R top-right shoulder row.
* Menu hamburger top-left, lives in its OWN sibling layer so it stays
visible when a physical gamepad is paired (most pads don't have a
"settings" button bound).
Mappings (matches LUS's defaults in ControllerDefaultMappings.cpp):
* A → SDL_CONTROLLER_BUTTON_A
* B → SDL_CONTROLLER_BUTTON_B
* Start → SDL_CONTROLLER_BUTTON_START
* Z → SDL_CONTROLLER_AXIS_TRIGGERLEFT (via setTrigger helper)
* R → SDL_CONTROLLER_AXIS_TRIGGERRIGHT (via setTrigger helper)
* Menu → toggleMenu() JNI → atomic flag → SDL_main thread
calls GetMenu()->ToggleVisibility()
Previously Z was incorrectly bound to SDL_CONTROLLER_BUTTON_LEFTSHOULDER
(which is N64 L) and there was no R at all.
setTrigger() helper hides the SDL trigger-axis convention
(-32768 = released, +32767 = pressed) behind a boolean so callers stay clean.
Layered visibility:
* gameplayLayer (stick + face buttons + shoulders + Start) — hidden
when a physical gamepad is paired OR when the LUS menu is open.
* menuLayer (hamburger only) — hidden only while the menu is actually
open. Stays visible with a paired gamepad so the user can still
reach settings.
Polling is logged to logcat tag ssb64.touch — `adb logcat -s ssb64.touch`
shows every controller-watch refresh and per-device enumeration to
help debug a missing-gamepad case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Android users dismiss the app by swiping it off recents — a "Quit" button in the menu is redundant and out of place on mobile. Gate the ICON_FA_POWER_OFF button + its callback on !__ANDROID__ and shift the SameLine offset down by one button-width so the remaining two header buttons (Reset + Close Menu) stay flush-right. PopStyleVar for the FramePadding push gets handled in both branches — order preserved so Reset/Close don't inherit the inflated padding that was meant for the headers + Quit button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New build-android job parallels build-{macos,linux,windows}:
* runs-on: ubuntu-22.04 (lightest available; no special platform deps)
* No matrix — Android is pinned to SSB64_VERSION=us at the CMake level,
so there's nothing to matrix.
* actions/setup-java@v4 → JDK 17 (AGP 8.x requirement).
* android-actions/setup-android@v3 with explicit
packages='platforms;android-34 build-tools;34.0.0 ndk;29.0.14206865'
— pins the NDK to the exact version android/app/build.gradle.kts
declares (older NDKs choke on the ptrdiff_t shim in
decomp/include/stddef.h).
* Pre-generates f3d.o2r with a single `cmake -E tar` invocation
(skips the full CMake bootstrap — Gradle's externalNativeBuild
drives that during assembleRelease; we just need the file at repo
root so the stageGameAssets task resolves it at configure time).
* Optional release-keystore decoding: when the repo defines secret
SSB64_ANDROID_KEYSTORE_BASE64 (base64 of release.jks) plus the
matching password/alias secrets, the if: gate decodes the file and
exports the env vars build.gradle.kts looks for. Without the
secrets, gradle falls back to debug signing — the APK still runs
locally, just isn't Play-Store-distributable.
* Builds both assembleRelease (.apk) and bundleRelease (.aab),
normalizes the output names under dist/ so the release job's
files: list is stable regardless of signing config.
* NEVER bundles ROM-derived data — same end-to-end ROM-independence
invariant as the other platforms: f3d.o2r and yamls/us are open
asset metadata; BattleShip.o2r is produced on-device from the
user's own ROM on first launch.
Release job's needs: + files: list extended to include the two new
android artifacts. No -JP variants because the Android build is
US-only.
Required repo secrets for signed Play-Store releases (all optional —
unset → debug-signed APK):
SSB64_ANDROID_KEYSTORE_BASE64
SSB64_ANDROID_KEYSTORE_PASSWORD
SSB64_ANDROID_KEY_ALIAS
SSB64_ANDROID_KEY_PASSWORD
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle format is Play-Store-only — needs Google's bundletool on the user's device to install. This project distributes via direct sideload from GitHub Releases, so the AAB just clutters the release page with something nobody can actually use. Skip bundleRelease, drop the AAB from the upload-artifact glob and from the release files: list. Easy to put back later if Play distribution is ever in scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the Android port — dormant since the v0.7.6-beta era — up to date on top of current main, with all gameplay-side polish, the LUS submodule on a current pin, and a new CI job that builds the APK on every `v*` tag.
What landed
Cross-platform verification
ROM safety
APK assets contain only `f3d.o2r` (open-source Fast3D shaders), `config.yml`, and `yamls/us/*` (layout metadata). `BattleShip.o2r` is produced on-device from the user-supplied baserom on first launch (`libtorch_runner.so` + SAF picker). Never bundled.
Test plan
🤖 Generated with Claude Code