phonehack architecture
Table of Contents
How the pieces fit: the shim windowport boundary, the non-cheating invariant, the two-axis settings model, the Feel system, the save/restore model, and the test layers. The guiding rule is that everything integrates through the shim windowport so the native NetHack core stays untouched and upstream bumps stay mechanical.
Layering
Compose UI (GameScreen, UiLab, Widgets, ...) Kotlin, our code, GPL
| StateFlow<UiState> down, queue offers up
GameSession Kotlin state machine
| JNI (NetHackBridge) <-- the "JNI line"
nethack_jni.c NetHack-derived, NGPL
| shim windowport callbacks (win/shim/winshim.c)
libnh.a (NetHack 5.0 core + Lua) upstream, untouched, NGPL
The core is the same static library the official WebAssembly port builds; we link against it and drive it exactly as WASM does, through the shim pseudo-windowport. We patch no game logic (see patches/README).
The shim windowport boundary
nethack_jni.c runs nhmain() on a dedicated pthread (the "game thread").
Every windowport operation the core performs - draw a glyph, put a string, start
a menu, ask a question - arrives on that thread as a shim callback and is
forwarded to a @JvmStatic method on org.labich.phonehack.NetHackBridge.
Dispatch by callback name
The shim delivers (name, ret_ptr, fmt, ...varargs). nethack_jni.c dispatches
on name and reads the true C vararg types directly; the fmt string is
advisory (only the WASM port decodes it). So the contract that must stay in sync
across an upstream bump is the set of callback names and their C signatures in
win/shim/winshim.c, mirrored by the shim_* branches in nethack_jni.c and
the cb* methods in NetHackBridge.kt.
The parked game thread
Blocking input callbacks - nhgetch, nh_poskey, yn_function, getlin,
select_menu, get_ext_cmd - park the game thread inside the JNI upcall: the
callback blocks on a Kotlin ArrayBlockingQueue until the UI thread offers a
result. The UI thread never calls into native code; it only publishes state
down and offers input up, so the two threads never contend except on the
StateFlow.
nh_poskey is the top of the main command loop. When the core parks there, the
hero has settled, so u.ux/u.uy is authoritative (the map cursor is not) - the
shim pushes it up via a setHero callback before blocking.
Read-only queries and the parked-safe-point discipline
Beyond the callbacks, NetHackBridge exposes read-only nativeQuery* functions
(tile, surrounding context, "what is at (x,y)", inventory, spells, end-of-game
summary, identity). These reach directly into core state off the UI-driven
path, so they are only safe when the game thread is parked at a top-of-loop
safe point and quiescent.
The discipline: every such query is gated on GameSession.mapReady(), which is
true only when mode == NORMAL && blockedInPoskey - i.e. the core is genuinely
parked in nh_poskey waiting for a command. nativeInGetpos() similarly reports
when the core is parked in the ';'/look cursor. Callers that skip the gate get a
no-op/null, never a race into live core state.
Menu identity stays C-side
Menu anything identifiers never cross the JNI boundary. C keeps them
per-window; Kotlin selects menu items by index; C rebuilds the MENU_ITEM_P
array the core then frees. This keeps the opaque core type out of Kotlin.
Process exit
NetHack ends a game (death, ascension, save-and-quit) by calling exit().
Running libc exit() on the game pthread would run __cxa_finalize and destroy
C++ statics still live in ART, aborting with a destroyed-mutex SIGABRT. The
link wraps it (-Wl,--wrap=exit): __wrap_exit flushes and calls _exit, so
the process terminates cleanly after NetHack has already shown its end screens
and closed its files. The app then relaunches fresh and restores.
The non-cheating invariant
The UI must only surface information the player character could already perceive in the classic game. This is enforced at the boundary, not by convention:
- Combat feed.
patches/0003adds three bare one-linerph_combat_event()calls at upstream combat funnel points (hero hits/kills a monster, a monster hits the hero). All logic lives innethack_jni.c, which fires the event only when the combatant is perceivable (canspotmon), forwards only a Hallucination-safe name (distant_monnam) plus the target tile and an event kind - never a damage number, to-hit roll, or monster HP. Disposition (hostile/peaceful) is claimed only when the monster is actually seen (canseemon), else "unknown". - Threats expire when perception ends.
GameSessiontracks perceived-hostile tiles for on-screen rings/arrows, andpruneThreats()(run at every top-of-loop park) drops any tile that no longer shows a spottable monster (it moved, died, or left sight) or hasn't been refreshed for a few turns. A threat is never created from anything but a perceived event and never outlives perception. - Look-ups mirror the player's view.
nativeQueryAtanswers "what's on tile (x,y)?" off the remembered glyph (lookat), so it returns only what the hero has seen/remembers - null where the hero has no knowledge. - Own state only. Inventory and spell queries read the hero's own pack and
spellbook (BUC only when known), which the player can already list with
iand+. Nothing reads another creature's hidden state. - Test-harness inspect is non-cheating surface too. The on-device harness
(
android/test-harness/) reads game state throughTestHooks, and those reads are held to the same invariant as production queries. Prefer deriving an assertion from the displayed map/state (UiState.map,state.threats) - theadjwalkability classes andhere~/~wieldedfields are computed from the remembered glyphs the player already sees. Any native read a hook exposes (e.g.nativeQueryMonsters, which walksfmon) must gate every monster oncanspotmonand name it withdistant_monnam- never touching HP, to-hit, or an unspotted creature - exactly as a shipped query would. A harness accessor gets the same non-cheating review as a production one; it must never become a back door to ground truth the hero has not perceived.
The rule of thumb for any new UI feature: if the classic tty game wouldn't show it to the player right now, neither may we.
The two-axis settings model
Settings split into two independent axes that never bleed into each other.
Axis 1 - NetHack rc options (GameOptions / GameSettings)
The standard quality-of-life / display / interface rc options we let the player toggle (autopickup, runmode, mentionwalls, hilitepet, sortloot, paranoid, …)
- nothing that reveals hidden state.
GameOptions.rcLinerenders them to one
explicit OPTIONS= line. At launch, writeLaunchRc takes the bundled
nethackrc, strips any token in MANAGED_OPTS from it (so nothing is declared
twice), appends our OPTIONS line, then the chosen character - NetHack honors
the last setting, so the player's choice wins. Launch mode (normal / explore /
wizard) is a command-line flag here, not an rc option.
Glyph-flag options the renderer must honor. Some rc options are realized
through glyph flags the core sends on every print_glyph. hilite_pet and
hilite_pile set MG_PET / MG_OBJPILE on the glyph. Because our map is drawn
by Compose (not a tty), the renderer itself must respect these: cellWash() in
GameScreen.kt applies the pet/pile wash only when the corresponding option is
on. If the renderer ignored the flag, toggling the rc option would silently do
nothing. Any future glyph-flag-realized option has the same obligation.
Axis 2 - UI extensions (UiPrefs / UiSettings)
Everything phonehack adds over the classic game: HUD layers (rich status bar,
condition pills, here-panel, damage flash, minimap, spell strip, rich
inventory, combat-feed consumers), input affordances (map-tap gestures,
direction rose, tile long-press menu, control bars, haptics), auto-move stop
conditions, theme, and Feel. Each is an individual Boolean~/value in
~UiPrefs, persisted to SharedPreferences.
Purist presets (Full / Classic / Bare)
Three functions in UiLab.kt set the layers in bulk:
presetFull- every visual layer and input affordance on.presetClassic- our added overlays off, but the touch input aids kept (map taps, direction rose, haptics, control bars). The visual-only purist.presetBare-presetClassicwith the touch aids off too, leaving only the on-screen control bars for input. The strict purist.
Named view presets (ViewPresets) additionally let a player save/restore the
whole layer configuration by name.
The Feel system
Orthogonal to the color theme, a Feel sets the structural language of the
non-game chrome (Feel enum in Widgets.kt, held in Style.feel):
- Bare Prompt (
PROMPT, the default) - a spare shell: hairline rules, lowercase labels, a live cursor. - TUI Frame (
TUI) - boxed, reversed-bar terminal styling.
Composables branch on Style.feel (e.g. the direction rose renders as a
bordered terminal cell under TUI, a soft filled key under PROMPT).
Save / restore model
- NetHack writes one save per character as
save/<uid><name>under the app's files dir. Because the save blob is opaque, a per-name SharedPreferences sidecar mirrors character/progress for the resume list, refreshed from live status fields (and forced on a background save). - Game over or save-and-quit exits the process (via the wrapped
exitabove). The app relaunches fresh and restores from the save file.
Blocking background-save latch
When Android backgrounds the app, MainActivity.onStop calls
GameSession.requestBackgroundSave(), which blocks the lifecycle transition
until the save actually reaches disk:
- It clears the
saveDonelatch, then triggers the save. Fast path: if the core is parked at a top-of-loop safe point (mapReady()and not in getpos), it injectsSdirectly. Escape path: otherwise (mid-menu / mid-prompt) it setsbgSaveRequestedand wakes the parked blocking call, which unwinds any open prompt (ESC/cancel) back tonh_poskey, whereposKeythen issues the save. - It blocks on
saveDone.poll(5s). The latch is released by theexitWindowscallback - which the core reaches only after the save has fully written - so Android cannot freeze the process mid-dosaveand leave a half-written, corrupt multi-level save. An aborted save raises afailed.<name>flag.
This latch is exactly what the on-device E2E harness guards (below).
Test layers
Four layers, each guarding what the ones below it can't. Run commands are in BUILDING.
- JVM unit + Robolectric UI-integration (
app/src/test/) - pure logic (options rc rendering, save enumeration, yn parsing, feature compaction, …) and Robolectric-driven Compose rendering of the game surfaces/layers. No native.so. - Instrumented Compose smoke (
app/src/androidTest/) - the real APK renders on an emulator (connectedDebugAndroidTest). - Native host C harness (
build/test/) - the NetHack core built natively for the host (via theCROSS_TO_HOSTtest codepath,patches/0004) exercising save/restore/negative-control integrity off-device, faster and without the Android stack. - On-device E2E (
android/test-harness/) - drives the real app through background-save -> process-death -> restore on an emulator/device, asserting a complete multi-level, multi-dungeon save every time (the JNI boundary, GameSession's save orchestration, the Activity lifecycle, and the on-disk files the host harness can't reach).