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 the ANDROID_NDK environment variable if it lives elsewhere. r27d is what the cross-compile Makefile hints default to (patches/0001).
  • Gradle wrapper 9.5.0 (bundled; ./gradlew fetches 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-v8a only (build.gradle.kts abiFilters).

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:

  1. Applies the patches to upstream/ (reverse-check first, so re-runs are no-ops). 0001 is fatal-if-missing; the two event hooks (0002, 0003) apply non-fatally and degrade gracefully if upstream drifts.
  2. Edits upstream/include/config.h with two appended guards (idempotent, sentinel-checked): #undef COMPRESS and #define REPRODUCIBLE_BUILD (see below). These are config edits, not patches.
  3. Host setup: sys/unix/setup.sh generates the Makefiles; the vendored Lua is copied into upstream/lib/lua-5.4.8 (fetch-lua fallback only if the vendored tree is absent); make lua_support generates nhlua.h.
  4. Per-ABI static libs: for each ABI in $ABIS, make CROSS_TO_ANDROID=1 ANDROID_ABI=<abi> ANDROID_API=26 ... androidpkg builds libnh.a, lua548.a, hacklib.a under upstream/targets/android/<abi>/.
  5. Links libphonehack.so per ABI directly with NDK clang (no CMake) from nethack_jni.c + the three static libs, into src/main/jniLibs/<gradle-abi>/.
  6. Stages assets into src/main/assets/nethackdir/: nhdat (the DLB data container), sysconf, nethackrc, and a version stamp.

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.