Skip to content

Android port — rebased over main + CI#208

Merged
JRickey merged 30 commits into
mainfrom
agent/android-port-rebased
May 24, 2026
Merged

Android port — rebased over main + CI#208
JRickey merged 30 commits into
mainfrom
agent/android-port-rebased

Conversation

@JRickey
Copy link
Copy Markdown
Owner

@JRickey JRickey commented May 24, 2026

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

  • 25 phase commits cherry-picked in order (Phase 1 spike → Phase 8.8 ROM re-extraction Intent), replayed cleanly over main's current tree.
  • 3 layering commits: drop `ssb64.o2r` from the APK bundle (no longer exists upstream), retarget LUS submodule to the new `ssb64-android-rebased` branch, and Android platform gates (US-only, drop hires-pack / post-process shaders / shader downloader / updater / Discord on Android — both source-excluded in CMake and hidden from the menu).
  • 3 submodule fixes that NDK r29 surfaced: decomp `stddef.h` shim now defines `ptrdiff_t`; libultraship's post-process `GL_FRAMEBUFFER_SRGB` calls gated on `!USE_OPENGLES`; new `hidapi` stub for the NDK sysroot (no libusb-1.0 available).
  • Touch overlay polish: 6 new N64-colored vector drawables (flat-plastic look), layered visibility (gameplay layer hides when a controller is paired or the menu is open; hamburger stays visible so settings stay reachable), Z/R correctly mapped to trigger axes with a clean `setTrigger(axis, pressed)` helper, JNI menu-toggle deferred to the SDL_main thread.
  • Menu "Quit BattleShip" header button gated off on Android — mobile users dismiss apps via swipe.
  • New `build-android` CI job in `.github/workflows/release.yml`. Ubuntu-22.04 + JDK 17 + Android SDK + NDK r29 pinned to the build.gradle version. Optional release-keystore signing via four repo secrets; falls back to debug signing if secrets aren't configured.

Cross-platform verification

  • Desktop macOS build still clean (792/792 TUs, `BattleShip` binary 40 MB, `BattleShip.o2r` staged — see f0645e6 / build log).
  • Android APK builds clean (151 MB; libmain.so + libtorch_runner.so + libSDL2 + libc++_shared + non-ROM-derived assets only).
  • All Android-disabled features (`Updater.cpp`, `DiscordRichPresence.cpp`, `ShaderDownloader.cpp`, `HiResHook.cpp`, `HiResPack.cpp`, `port_window_icon.cpp`) confirmed still compiled into the desktop binary — the CMake gates fire on `CMAKE_SYSTEM_NAME STREQUAL "Android"` only.

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

  • `./gradlew assembleDebug` succeeds (verified locally)
  • APK installs and runs on the `ssb64test` AVD; Hyrule Castle attract demo renders end-to-end
  • Z trigger releases correctly (not stuck-on)
  • Menu hamburger opens LUS settings; settings remain touch-interactable
  • Desktop macOS build clean — no regressions to Updater / Discord / HiRes / ShaderDownloader / window-icon paths
  • CI run on tag push produces `BattleShip-android.apk` artifact (validates the new build-android job end-to-end)
  • Sideload installs of the CI-produced APK update cleanly across versions (validates the signing-keystore wiring)

🤖 Generated with Claude Code

JRickey and others added 30 commits May 24, 2026 01:50
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>
@JRickey JRickey merged commit e6e10e8 into main May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant