Building phonehack
Table of Contents
How the app is built: the one-command Gradle path, the native cross-compile it
drives, the flags for partial/lean builds, and the four test layers. The test
commands here are executable: they tangle into a runnable verifier
(make verify-docs) so the documented toolchain cannot silently drift.
Prerequisites
- JDK 17. The app targets Java 17 / Kotlin JVM 17 (
android/app/build.gradle.kts). - Android SDK.
compileSdk~/~targetSdk= 36,minSdk= 30. - Android NDK r27d at
$HOME/opt/android-ndk-r27d. Override with theANDROID_NDKenvironment variable if it lives elsewhere. r27d is what the cross-compile Makefile hints default to (patches/0001). - Gradle wrapper 9.5.0 (bundled;
./gradlewfetches it). - The pinned NetHack core as a submodule:
git submodule update --init. - Lua is vendored in-tree (
third_party/lua-5.4.8); no network is needed to build.
One-command build
git submodule update --init
cd android && ./gradlew assembleDebug
# APK -> android/app/build/outputs/apk/debug/app-debug.apk
A Gradle preBuild hook runs the buildNativeLibs task, which shells out to
build/build-android-libs.sh. That script does the whole native side and stages
the APK assets, so the single Gradle invocation produces a complete APK.
- Debug builds both ABIs:
arm64-v8a+x86_64(device + emulator). - Release builds
arm64-v8aonly (build.gradle.ktsabiFilters).
You can also invoke the wrapper without cd:
android/gradlew -p android :app:assembleDebug
Gradle flags
| Flag | Effect |
|---|---|
-PskipNativeBuild |
Skip the native cross-compile entirely (JVM unit tests don't load the .so). |
-x :app:buildNativeLibs |
Skip the native task for this run but keep the wiring (reuse a prebuilt/staged .so). |
-PincludeWiki |
Add src/wiki/assets (the ~44 MB offline NetHackWiki) to the APK. Omitted, the F-Droid/lean build ships no wiki. |
-PskipNativeBuild removes the preBuild -> buildNativeLibs dependency;
-x :app:buildNativeLibs leaves the task declared but excludes it from the run
(use it when the .so is already staged and you only changed Kotlin).
The native cross-compile (build/build-android-libs.sh)
Invoked by Gradle, but safe to run standalone. It is idempotent and incremental
(the underlying NetHack build is make-driven). In order it:
- Applies the patches to
upstream/(reverse-check first, so re-runs are no-ops).0001is fatal-if-missing; the two event hooks (0002,0003) apply non-fatally and degrade gracefully if upstream drifts. - Edits
upstream/include/config.hwith two appended guards (idempotent, sentinel-checked):#undef COMPRESSand#define REPRODUCIBLE_BUILD(see below). These are config edits, not patches. - Host setup:
sys/unix/setup.shgenerates the Makefiles; the vendored Lua is copied intoupstream/lib/lua-5.4.8(fetch-lua fallback only if the vendored tree is absent);make lua_supportgeneratesnhlua.h. - Per-ABI static libs: for each ABI in
$ABIS,make CROSS_TO_ANDROID=1 ANDROID_ABI=<abi> ANDROID_API=26 ... androidpkgbuildslibnh.a,lua548.a,hacklib.aunderupstream/targets/android/<abi>/. - Links
libphonehack.soper ABI directly with NDK clang (no CMake) fromnethack_jni.c+ the three static libs, intosrc/main/jniLibs/<gradle-abi>/. - Stages assets into
src/main/assets/nethackdir/:nhdat(the DLB data container),sysconf,nethackrc, and aversionstamp.
ABIS and building both ABIs
The script builds every ABI named in the ABIS environment variable, default
"aarch64 x86_64" (upstream NDK-triple names; mapped to Gradle
arm64-v8a~/~x86_64 dir names internally). Build both so the app runs on real
arm64 devices and the common x8664 emulator:
ABIS="aarch64 x86_64" bash build/build-android-libs.sh # default
ABIS=aarch64 bash build/build-android-libs.sh # arm64 only (faster)
NDK / API / log overrides
ANDROID_NDK=$HOME/opt/android-ndk-r27d \
ANDROID_API=26 \
LOG=/tmp/phonehack-libs.log \
bash build/build-android-libs.sh
Full build output goes to $LOG (default /tmp/phonehack-libs.log) so
it can be reviewed without re-running.
16 KB page alignment
Android 15+ / Pixel 8-class devices use 16 KB memory pages. The JNI link passes
-Wl,-z,max-page-size=16384 so libphonehack.so loads on those devices; build
both ABIs (a 4 KB-aligned .so warns/faults on a 16 KB-page device). For the
same reason build.gradle.kts forces androidx.graphics:graphics-path:1.1.0
over the transitive 1.0.1, whose prebuilt .so is only 4 KB-aligned.
#undef COMPRESS
The linux.500 hints define -DCOMPRESS (save compression via an external
gzip~/~compress). That cannot work on Android: the forked child's
setgid/setuid raises SIGSYS under seccomp, and forking from the game thread
destabilizes the ART runtime. A failed fork also leaves a stray <save>.Z that
the next restore's uncompress deletes - silent save loss. The build appends
#undef COMPRESS to config.h (included after the command-line -D, so it wins
regardless of flag order), and patches/0001 additionally excludes
CROSS_TO_ANDROID from the config.h COMPRESS block. Saves are written and read
uncompressed.
Reproducible date
REPRODUCIBLE_BUILD makes makedefs consume SOURCE_DATE_EPOCH (derived from
the pinned upstream commit) instead of the wall clock, so include/date.h is
deterministic. An externally set SOURCE_DATE_EPOCH (e.g. a CI harness) still
wins.
Host-vs-Android upstream/ Makefile pollution (gotcha)
The Android build and the native host test build (below) both run
setup.sh~/~make inside upstream/, and each bakes its own target's hint
blocks into the generated Makefiles. They are not compatible. An Android build
left in place will make the host build skip its Makefile regen, and vice versa.
Reset upstream/ between a host build and an Android build. The host harness
does this for you:
git -C upstream reset --hard
git -C upstream clean -fdxq # vendored Lua is re-copied; still no network
build/test/build-host.sh runs exactly this before building, so switching from
Android to host is automatic; switching back to Android (or if a host build
was interrupted) may need a manual reset so the Android setup.sh re-runs.
Vendored Lua
NetHack 5.0 embeds Lua 5.4.8. The stock recipe would curl it from lua.org, but
F-Droid and the offline build container forbid build-time downloads. It is
vendored at third_party/lua-5.4.8 (MIT; see third_party/lua-5.4.8/PROVENANCE.md)
and copied into upstream/lib/lua-5.4.8 at build time, so a clean build needs no
network. make fetch-lua is only a fallback if the vendored tree is missing.
Test layers
There are four, from fastest/most-isolated to most-integrated. See ARCHITECTURE for what each layer guards.
1. JVM unit + Robolectric UI-integration tests
Pure-JVM tests (incl. Robolectric-driven Compose rendering) under
app/src/test/. They don't load the native .so, so skip the NDK build:
cd android
./gradlew :app:testDebugUnitTest -PskipNativeBuild
# coverage (HTML+XML) -> app/build/reports/jacoco/jacocoTestReport/html/index.html
./gradlew :app:jacocoTestReport -PskipNativeBuild
2. Instrumented Compose smoke test (on device/emulator)
app/src/androidTest/ (GameScreenSmokeTest.kt) - a real APK on an emulator:
android/gradlew -p android :app:connectedDebugAndroidTest
3. Native host C harness (build/test/)
Builds libnh.a natively for the host (x8664 Linux, system compiler) via a
test-only CROSS_TO_HOST codepath (patches/0004) and links a C harness that
exercises save/restore integrity off-device. Self-resets upstream/ first.
build/test/run.sh # build-host.sh + save/restore/negative suite
# env: NETHACK_TEST_SEED (default 1), HARNESS_TIMEOUT secs (default 60)
4. On-device E2E save-integrity harness (android/test-harness/)
Drives the real app through background-save -> process-death -> restore on an emulator/device, asserting a complete multi-level save every time.
# Build once (native .so is prebuilt/staged; do not rebuild it here):
android/gradlew -p android :app:assembleDebug -x :app:buildNativeLibs
# Run against a connected device (auto-detected):
android/test-harness/save_integrity_test.sh
# Or bring up a headless emulator, then target it:
android/test-harness/emulator.sh up # prints the serial
PH_SERIAL=emulator-5560 android/test-harness/save_integrity_test.sh
Target an unlocked emulator (a locked/dozing phone never fires onStop, so the
background save can't be triggered over adb). Full knobs and the helper API are
in android/test-harness/README.md.
Keeping this document honest
The layer commands above are not just illustrations. The same invocations are
tangled into a runnable verifier, build/verify-docs.sh, so a command that
drifts out of date fails a check instead of quietly misleading a reader.
Re-tangle it from this file and run it with:
make verify-docs
It runs every layer in order - the JVM unit suite, the native host
save-integrity suite, and, when a device or emulator is attached, the
instrumented smoke test and the on-device E2E harness - and exits nonzero if any
documented check fails. It is CI-weight, not a quick local smoke test: the host
suite resets upstream/ (see the gotcha above) and the on-device layers need an
attached target, so run individual layers by hand during development and reserve
the full verifier for CI or a deliberate end-to-end pass.
# GENERATED by `make verify-docs` (tangled from BUILDING.org). Do not edit here.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
fail=0
git submodule update --init
test -f upstream/sys/unix/setup.sh \
|| { echo "FAIL: NetHack core submodule missing"; exit 1; }
# Layer 1 - JVM unit + Robolectric (deviceless; the .so is not loaded)
( cd android && ./gradlew :app:testDebugUnitTest -PskipNativeBuild ) || fail=1
# Layer 3 - native host save-integrity suite (deviceless; resets upstream/)
build/test/run.sh || fail=1
# Layers 2 + 4 - instrumented smoke + on-device E2E (need an attached target)
if adb get-state >/dev/null 2>&1; then
android/gradlew -p android :app:assembleDebug -x :app:buildNativeLibs || fail=1
android/gradlew -p android :app:connectedDebugAndroidTest || fail=1
android/test-harness/save_integrity_test.sh || fail=1
else
echo "SKIP layers 2+4: no device/emulator attached"
fi
[ "$fail" -eq 0 ] && echo "VERIFY-DOCS: PASS" || { echo "VERIFY-DOCS: FAIL"; exit 1; }
Reproducible offline build (F-Droid parity)
build/container/ reproduces an F-Droid buildserver: a pinned Debian +
systemd-nspawn container that installs the SDK/NDK, then runs
./gradlew --offline --no-daemon clean assembleRelease with no network to
prove nothing is fetched at build time. See build/container/README.md.