How to build, package, and run LBA2 Classic Community on Android
(arm64-v8a and armeabi-v7a). The build and packaging scripts live under
scripts/dev/ and scripts/packaging/.
Minimum Android version: 7.0 (API 24). The APK targets API 24 (required by SDL3) and is tested on API 24–36.
You need:
- Android NDK r28 or later (recommended: r28.2.13676358) — required for 16 KB memory-page support on arm64 (see 16 KB page support).
- SDL3 built for your target ABI(s).
- Ninja build system.
- Retail game data (HQR files) — you must provide these.
# arm64-v8a: clones SDL at the tag in .github/sdl3-version.txt to
# out/android/SDL3, installs to out/android/sdl3-install, and applies the
# 16 KB-page linker flags.
bash scripts/dev/build-sdl3-android.shThe source tree it leaves under out/android/SDL3 is also the
--sdl3-java-src the bundler needs for the SDLActivity Java sources. For
armeabi-v7a, configure SDL3 by hand with -DANDROID_ABI=armeabi-v7a
(the script targets arm64-v8a).
# From the repo root, point SDL3_ANDROID_DIR at the install from step 1
export ANDROID_NDK=$HOME/Android/Sdk/ndk/28.2.13676358
export SDL3_ANDROID_DIR=$PWD/out/android/sdl3-install
bash scripts/dev/build-android.sh # arm64-v8a (default)
bash scripts/dev/build-android.sh --abi armeabi-v7a # 32-bitThe build script produces out/build/android_arm64/SOURCES/liblba2cc.so
(or armeabi-v7a for 32-bit builds). To package into an APK, use the
bundler script:
bash scripts/packaging/bundle-android.sh \
--lib out/build/android_arm64/SOURCES/liblba2cc.so \
--version "$(cat out/build/android_arm64/VERSION.txt)" \
--arch arm64-v8a \
--build-dir out/build/android_arm64 \
--sdk-root $ANDROID_HOME \
--sdl3-java-src out/android/SDL3 \
--sdl3-lib $SDL3_ANDROID_DIR/lib/libSDL3.so \
--cxx-shared-lib $ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \
--output-dir dist
# Install
adb install -r dist/lba2cc-*-android-arm64-v8a.apk--sdl3-lib is required — without it libSDL3.so is omitted from the APK
and the app fails to load. --cxx-shared-lib points the bundler at the
NDK's libc++_shared.so; CI passes both explicitly.
Android 15+ runs 64-bit apps on 16 KB memory pages on some devices. A
native library whose LOAD segments are 4 KB-aligned fails to load there
(... program alignment (4096) cannot be smaller than system page size (16384)). The arm64-v8a build is 16 KB-safe:
- NDK r28 ships
libc++_shared.so16 KB-aligned and defaults the linkermax-page-sizeto 16384. r26 cannot be fixed by flags alone — its prebuilt STL is 4 KB-aligned. -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384, set in theandroid_commonpreset and the build scripts, keepliblba2cc.soandlibSDL3.soaligned even on r27.- The APK stores the
.souncompressed and 16 KB-zipaligned withextractNativeLibs="false", so the loader maps them straight from the APK. scripts/dev/check-16k-align.sh <apk>verifies all of the above; CI runs it on every arm64 build.
Backward-compatible: max-page-size sets a maximum, so a 4 KB-page
kernel loads the libraries normally. armeabi-v7a is unaffected — 16 KB
pages are a 64-bit concern; 32-bit ARM (older devices, all current
Android TV) is always 4 KB.
Place your retail LBA2 .HQR files on the device with ADB. Recommended:
/sdcard/lba2cc/ — the game probes it ahead of the other external roots and
it survives an uninstall/reinstall:
adb push LBA2.HQR /sdcard/lba2cc/
adb push RESS.HQR /sdcard/lba2cc/
# ...and the rest of your retail HQR set/sdcard/lba2cc/ is general external storage, so on Android 11+ (API 30+)
the app needs All Files Access (MANAGE_EXTERNAL_STORAGE). The game checks
for it on launch and opens the system Settings page if it is missing. Grant it
and come back: the launch is waiting for the answer and carries on with it, so
the saves land in the right folder the first time. Declining is not fatal either,
though the game data cannot be read without it. You can also pre-grant it:
adb shell appops set org.lbalab.lba2cc MANAGE_EXTERNAL_STORAGE allowThe app-specific dir /sdcard/Android/data/org.lbalab.lba2cc/files/ needs
no permission, but on stricter API 30+ / TV images files adb pushed there can
be invisible to the app's own view, so prefer /sdcard/lba2cc/.
ResolveGameDataDir (SOURCES/RES_DISCOVERY.CPP) scans, in order:
--game-dir/--data-dirCLI flagLBA2_GAME_DIRenvironment variable- Persisted last-used directory (from a previous picker session)
- SDL base path and its
data//game/subdirs - Current running directory
- App-specific external-files dir (Android; no permission) —
/sdcard/Android/data/<pkg>/files/ /sdcard/lba2cc/and/storage/emulated/0/lba2cc/, then/sdcard/and/storage/emulated/0/- Parent-directory walk
- Folder picker fallback (if compiled with debug tools)
The two lba2cc/ folders accept either extracted retail files or a disc image
such as a BIN/CUE rip. The bare external-storage roots only check for extracted
files, avoiding an expensive scan across an entire shared-storage directory.
Saves, lba2.cfg, adeline.log, adeline.prev.log and recordings/ all live
in the user directory, and on Android that is:
/sdcard/lba2cc/user/LBA2/
After a native crash, the next launch also saves the system's tombstone of it
there as crash-<time>.pb, replacing the one saved before, and the boot banner
says so on a Note: line. Android 12 or later keeps the tombstone; Android 11
records only that the crash happened. The file is a protobuf, decoded off device
with AOSP's pbtombstone or protoc --decode against tombstone.proto. The
engine's own CRASH block for the same crash is in the log of that run.
Beside the game data rather than inside it, so backing up /sdcard/lba2cc/
takes both while keeping the player's files apart from a retail set they may
re-push at any time.
The last element names the build, the same way the desktop paths do
(Twinsen/LBA2/ per user, User/LBA2/ portable). A demo build writes to
user/LBA2-Demo/ and so keeps its own saves, and a second game on this engine
gets a folder of its own without anything having to move.
That folder is ordinary external storage, so a file manager, a PC over USB and
any backup tool can read it, and it survives uninstalling the app. Until
0.13.0 the user directory was whatever SDL_GetPrefPath returns, and on
Android that call ignores the org and app it is given and hands back
Context.getFilesDir(), which is /data/data/<pkg>/files/. Nothing reaches
that folder on a stock device: adb shell gets Permission denied, run-as
refuses a non-debuggable package, and adb backup returns an empty archive
even with android:allowBackup="true" set. The system also erases it on
uninstall.
If All Files Access is not granted, /sdcard/lba2cc/user/LBA2/ cannot be
written to, and the game falls back in this order:
/sdcard/lba2cc/user/LBA2/ |
needs All Files Access; survives uninstall |
/storage/emulated/0/lba2cc/user/LBA2/ |
the same folder under its other name |
/sdcard/Android/data/<pkg>/files/ |
no permission needed; erased on uninstall |
SDL_GetPrefPath |
app-private; unreachable; erased on uninstall |
Which one is in force is decided by writing a probe file, not by asking whether the permission is held: on Android 11+ a path can be readable, listable and unwritable at the same time, and the permission can be revoked between two launches. A fallback is named in the boot banner, described below.
Nothing is stranded when the answer changes. If the chosen folder holds no user data and one further down the list does, the tree is copied up on the next launch, profiles included. That covers both the move out of app-private storage and the case where a player grants All Files Access after already playing. Nothing is deleted and nothing is overwritten: a folder that has been played in is left exactly as it is.
"Has been played in" means it holds lba2.cfg, save/ or profiles/, not
that it is non-empty. The app-specific external folder is one of the roots
here, and this document tells players they may drop their retail HQR set
there.
If two folders both hold saves, both are left alone and the other one is named. Reinstalling drops All Files Access, so the next launch falls back and starts a fresh folder; anything played before the permission is granted again lands there and then stops being visible once the shared folder is reachable. Merging is not on the table, because both sets are somebody's real progress and nothing in the engine can tell which one they meant. The banner says where the other one is so it can be copied over by hand.
Whichever root loses, any folder that was carried up, and any second set of
saves are all named in the boot banner at the top of adeline.log on Note:
lines, so a pasted log says where the saves went and why.
check-android-userdir.sh asserts all of this against a connected device:
ADB=/path/to/adb scripts/dev/check-android-userdir.sh dist/lba2cc-*-arm64-v8a.apkIt covers the parts a host test cannot reach: that All Files Access really does
let the engine write to /sdcard, and that the atomic config write survives the
FUSE-emulated filesystem /sdcard actually is. The "granted the permission
later" case needs LBA2_ALLOW_REBOOT=1, because appops set grants the
operation without rebuilding a running app's view of storage: appops get then
reports allow with a rejectTime beside it, and the app keeps using the
fallback until the device reboots or the permission is granted through Settings.
Put extracted retail files without LBA2.CFG in /sdcard/lba2cc/ for the full
run. With a disc image there, the config write is skipped, because the game
reads the disc's own config and writes none on a first boot. The checks without
All Files Access are skipped too, because the game cannot open the image and
stops at "Game data not found". Checks that need root report SKIP on a retail
phone.
Since 0.13.0 every published APK is signed with the same key, so a new
version installs over the old one and your saves stay put.
Builds before that were each signed with a throwaway key generated by the CI job, which made every release a different app as far as Android is concerned. Installing one over another fails with "App not installed as package conflicts with an existing package", and uninstalling first was the only way through. That erased the saves, because they were in app-private storage.
Coming from a build older than 0.13.0 you have to uninstall once. From
0.13.0 onward, updates are updates. See
RELEASING.md for the key itself.
The gate is root, not a PC. Older builds kept saves in app-private
storage, which the uninstall erases, so a rescue has to happen before you
uninstall. A rooted device can do it on its own, and a PC does not help without
root: adb shell runs as the shell user, and the shell user cannot read another
app's private storage any more than you can.
On a rooted device, with no PC. A file manager with root access, or a backup app such as Swift Backup, copies the folder directly:
/data/data/org.lbalab.lba2cc/files/ -> /sdcard/lba2cc/user/LBA2/
Copy rather than move, then uninstall, then install the new build. The uninstall is what clears the app-private copy, and leaving one behind means every launch reports two sets of saves.
On a rooted device, from a PC. The same operation over adb:
adb root
adb shell "mkdir -p /sdcard/lba2cc/user/LBA2"
adb shell "cp -r /data/data/org.lbalab.lba2cc/files/. /sdcard/lba2cc/user/LBA2/"
adb uninstall org.lbalab.lba2cc
adb install lba2cc-0.13.0-android-arm64-v8a.apkEither way the new build finds a folder that already holds saves and settings and uses it as it is.
Without root there is no way to get them out. adb shell and adb pull are
refused by the sandbox, run-as only works on a debuggable build, and adb backup returns an empty archive even though the manifest allows backups: the
attribute that decides it is android:debuggable, which a release build does not
carry. A cloud backup does not help either, because restoring one requires the
signing certificate the old build was published with. Progress from a build
older than 0.13.0 is lost.
Rooting a device that is not already rooted does not rescue it. Rooting
generally requires unlocking the bootloader, and unlocking wipes user data,
destroying the saves the exercise was meant to recover. Reading the bootloader
state with fastboot getvar unlocked is safe; fastboot flashing unlock is not.
Only a device that is already unlocked, or already rooted, has a route.
The on-screen virtual gamepad uses the following layout:
+--(ESC)---------(HIDE)---------(MEN)(CAM)-+
| |
| |
| |
| |
| (U) (MD) (ACT) (USE) |
| (L) (D) (R) (THR) (INV) (W) |
+-------------------------------------------+
- ESC — Escape (quit/back)
- HIDE — Dismiss the controls now; the next touch brings them back
- MEN — Menu (F10 / in-game menu)
- CAM — Camera toggle (Backspace)
- D-pad — U/D/L/R → Arrow keys (movement)
- MD — Mode (Ctrl / behaviour mode)
- ACT — Action (Space / jump/interact)
- USE — Use/confirm (Enter)
- THR — Throw (Alt)
- INV — Inventory (Shift)
- W — Action (always-on action key, "W" for walk/run)
The layout is defined in SOURCES/TOUCH_INPUT.CPP and can be customised
by editing the kButtons[] table (normalised 0..1 coordinates).
It follows the last input device the player actually used, and fades when they stop using it:
| state | overlay |
|---|---|
| a finger is down | full opacity, for as long as it is held |
| ~1.5s after the lift | dimmed, still legible enough to aim by |
| ~6s after the lift | gone |
| a gamepad or key was the last input | gone, until a finger says otherwise |
| nothing has been touched yet | dimmed, or gone if a pad is connected or the device is a TV |
| HIDE was pressed | gone now, without waiting out the fade |
The first touch after using another device only wakes the overlay; it presses nothing. Every touch after that both wakes and presses, so a player who knows the layout never loses the press that mattered. It also stops a phone in a controller clip latching a direction when a palm brushes the glass.
Every one of those states is left by touching the screen. That is what removed the old "persistent pill when hidden": a control whose only job was to undo something a touch already undoes. HIDE is now a dismiss rather than a mode, so hitting it by accident (it sits where you tap to skip dialogue) costs one touch instead of stranding you in a state with an unexplained chip on screen.
The buttons are placed in coordinates normalised over the whole SDL surface, so
they sit on top of the game's letterbox bars rather than inside the rendered
picture. That is only coherent because the surface fills the display, which the
fullscreen coercion below guarantees on Android. Touch hit-testing does not
depend on it either way: MapToGameCoords reads the live window size, so it
follows the surface whatever size it is.
Window_SupportsWindowedMode() (LIB386/SYSTEM/WINDOW.CPP) returns false on
Android, so the Display submenu drops its Fullscreen/Windowed row and the window
is always fullscreen. CoerceFullscreenForPlatform ignores a windowed request
there: with the row gone, a player whose config asked for windowed would sit
behind the system bars with no control to undo it. The config value is kept
rather than rewritten, so a profile carried to desktop still honours it.
The manifest names @android:style/Theme.DeviceDefault.NoActionBar.Fullscreen.
With no theme the framework picks one that has an action bar, and SDL puts the
SDL window title into the activity title, so that bar draws the product name and
version over the game. DeviceDefault rather than the legacy
Theme.NoTitleBar.Fullscreen from SDL's android-project template, because the
theme also styles the dialogs SDL builds against the activity.
- Software renderer: The game uses a software 8-bit→ARGB pipeline. Performance on modern ARM64 devices is acceptable at 640x480. On older 32-bit ARM (armeabi-v7a) devices, expect lower framerates.
- CD audio: CD-ROM music tracks are not available. Use the digital
sample backend (
SOUND_BACKEND=sdl). - Text input: Console commands still require a hardware keyboard. Save-game naming no longer does — without a physical keyboard (touch, gamepad, or Android TV) the save menu auto-generates a name from the date and the current island instead of prompting.
- Save/resume: Android lifecycle pause/resume is handled by SDL3 (window focus events). Surface re-creation is managed by the existing SDL3 infrastructure.
- Game data: You must provide your own retail LBA2 data files.
- Android TV: the touch overlay starts off when a TV device is detected
(
android.software.leanbackfeature). Use a gamepad or remote control instead. Leanback is only the starting value, not a lock: a touch still brings the overlay up, so a leanback device that does have a touchscreen can use it. A first touch is better evidence than a manifest feature flag, and the alternative was a control the player has no way to reach.