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/0003 adds three bare one-liner ph_combat_event() calls at upstream combat funnel points (hero hits/kills a monster, a monster hits the hero). All logic lives in nethack_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. GameSession tracks perceived-hostile tiles for on-screen rings/arrows, and pruneThreats() (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. nativeQueryAt answers "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 i and +. 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 through TestHooks, 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) - the adj walkability classes and here~/~wielded fields are computed from the remembered glyphs the player already sees. Any native read a hook exposes (e.g. nativeQueryMonsters, which walks fmon) must gate every monster on canspotmon and name it with distant_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.rcLine renders 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 - presetClassic with 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 exit above). 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:

  1. It clears the saveDone latch, then triggers the save. Fast path: if the core is parked at a top-of-loop safe point (mapReady() and not in getpos), it injects S directly. Escape path: otherwise (mid-menu / mid-prompt) it sets bgSaveRequested and wakes the parked blocking call, which unwinds any open prompt (ESC/cancel) back to nh_poskey, where posKey then issues the save.
  2. It blocks on saveDone.poll(5s). The latch is released by the exitWindows callback - which the core reaches only after the save has fully written - so Android cannot freeze the process mid-dosave and leave a half-written, corrupt multi-level save. An aborted save raises a failed.<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.

  1. 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.
  2. Instrumented Compose smoke (app/src/androidTest/) - the real APK renders on an emulator (connectedDebugAndroidTest).
  3. Native host C harness (build/test/) - the NetHack core built natively for the host (via the CROSS_TO_HOST test codepath, patches/0004) exercising save/restore/negative-control integrity off-device, faster and without the Android stack.
  4. 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).