Files
questshock/AGENTS.md
T
ml 295f4869c1
build / build (push) Successful in 2m48s
Sign release APKs with a fixed key from CI secrets
2026-09-20 17:47:43 +02:00

22 KiB

AGENTS.md

Guidance for AI coding agents (and new contributors) working on Questshock. README.md is the user-facing build/usage guide and CONFIG.md documents the configuration file; this file covers how the code is organised and the rules to follow when changing it.

1. What this project is

Questshock plays the 1994 game System Shock on a standalone VR headset (Meta Quest 2 is the tested device). It wraps Shockolate, a cross-platform port of the original engine, in an Android app that:

  • runs the unmodified 2D engine (software or OpenGL renderer) and shows its frame on a head-tracked virtual screen (an OpenXR quad layer);
  • shows a menu and an on-screen keyboard as separate OpenXR quads, operated by a laser pointer from either controller;
  • maps controller inputs to game actions through a configurable binding layer;
  • persists settings in /sdcard/QuestShock/questshock.config.json.

A desktop (Linux/Windows) build of the same engine exists for development without a headset; it has none of the Android/OpenXR code.

2. Hard rules

These come from the maintainer. Follow them without being asked again.

  1. Never commit. Do not run git commit (or amend, rebase, push), even if asked to commit. Draft the commit message and show it; the maintainer runs the commit. Mention any new untracked file that has to be git added (typically a new patch under android/engine-patches/).
  2. Do not build. Do not run make apk, ./gradlew, or make android-studio to check that a change compiles. The maintainer deploys from Android Studio. make apk and make android-studio overwrite android/engine.properties with container-only paths and break an open Android Studio project. What you can do is dry-run patches (section 6) and run static consistency checks (section 13). Say plainly in your report that the change was not built or tested.
  3. engine/ and android/gl4es-src/ are never edited. They are vendored snapshots. Every change to them is a numbered patch under android/engine-patches/ or android/gl4es-patches/, applied to a scratch copy at build time. (engine/ is GPLv3, see README section 6.)
  4. Patch numbers stay contiguous (01, 02, ... with no gap). When you insert or delete a patch, renumber the rest and update every reference to the numbers (README patch reference, code comments).
  5. Comments and documentation describe the current state and why it has to be that way. No history or narrative: no "used to", "originally", "no longer", "now", "this was fixed", "confirmed on device", incident stories or dropped alternatives. History belongs in commit messages. A comment that would read oddly to someone who never saw an earlier version should be rewritten or cut. Default to one or two lines. (README section 5.5, the debugging notes, is the one deliberate exception.)
  6. Standalone headset only, OpenXR first. No feature may need a host PC (no PCVR, tethering or streaming). Prefer OpenXR APIs over vendor-specific ones (Meta's Oculus Mobile SDK and the like) so the port can run on other Android OpenXR headsets. Flag a request that would break either rule instead of quietly doing it.
  7. Do not bump build-image/VERSION just because build-image/Dockerfile changed. Bump it only once a build with the current number has succeeded end to end (for example a CI run). Ask if unsure.
  8. Keep docs in sync with behaviour. A user-visible change updates README.md and/or CONFIG.md in the same change; a new or renumbered engine patch updates the README patch reference (section 5.4).

3. Repository layout

Path What it is
engine/ Vendored Shockolate source. Read-only for us.
android/engine-patches/ Numbered patch -p1 files applied to a scratch copy of engine/.
android/gl4es-src/, android/gl4es-patches/ Vendored GL4ES (desktop GL to GLES) and its patches.
android/app/src/main/cpp/ First-party native code (section 4).
android/app/src/main/java/de/ladkau/questshock/ First-party Java (section 4). org/libsdl/app/ is vendored SDL2 glue (zlib).
android/app/build.gradle Gradle project; the stageEngine task stages the patched engine.
android/force-rebuild.sh Stops Gradle daemons and wipes staged/build output for a fully fresh native build.
build-image/ Docker build environment (Dockerfile, VERSION, prepare-android-project.sh, build-apk.sh, engine build scripts).
res/ Bundled resources: questshock.config.default.json, controller_profiles/ (JSON + PNG per controller), splash.png (startup splash), logo.png (1024x1024 logo, master of the launcher icons in android/app/src/main/res/mipmap-*/), logo_small.png (the README header), assets/ (where the user's own game data goes, git-ignored), run.sh/run.bat.
.gitea/workflows/build.yml, .github/workflows/release.yml CI. Gitea is the primary repository and builds on every push; GitHub is a mirror whose workflow builds and publishes a release when a vX.Y.Z tag is pushed. Each platform has its own file.
Makefile engine, dist, package, apk, android-studio, clean, ... (see README section 2).
README.md, CONFIG.md, NOTICE.txt User docs, config reference, license notice.

build/, dist/, dist-win/ and res/assets/ss_ee/ are generated or proprietary and git-ignored. registry.env holds local registry settings; do not print it or commit it.

4. Android app architecture

4.1 Native modules (android/app/src/main/cpp/)

File Role
xr_session.c/.h OpenXR instance and session lifecycle, frame loop, composition layers (game quad, menu, keyboard, laser beams).
xr_swapchain.c/.h Swapchain creation, acquire and release, per-image FBOs.
xr_overlay.c/.h Generic "off-screen Android View rendered into an OpenXR quad" module, hit-tested by a laser pointer. The menu and the keyboard are two instances.
xr_input.c/.h Controller input: OpenXR actions, raw-input layer, action bindings, capture-mode rebinding, key injection into the engine, free view, cyberspace scaling, snap-turn/smooth-move of the player's position, pointer rays. The largest file.
xr_mouse.c/.h Injects mouse motion and clicks into the engine.
qs_config.c/.h Configuration: JSON parse, apply, write; screen profiles; menu-driven requests; cheats; menu-triggered pause.
qs_splash.c/.h Startup splash: qs_splash_show() fades the image (res/splash.png, decoded by Java and passed in through nativeSetSplashImage) in and out on the game screen. Called from engine patch 27.
questshock_native.c JNI exports and small native helpers (nativeChdir, nativeSendPrintableChar, ...).

4.2 Java classes

Class Role
QuestShockActivity SDLActivity subclass. Creates /sdcard/QuestShock/, extracts bundled files, blocks engine start until the game data exists.
OverlayPanel Base for the off-screen panels: builds the view tree, renders it to a texture, turns ray hits into touch events.
MenuOverlay The menu: Main Menu, Video Options, Controller Options and Cheats tabs. Singleton.
KeyboardOverlay The on-screen keyboard.
Strings All menu and keyboard text in English, French and German.

4.3 How the pieces fit

  • The engine's present path is redirected (engine patch 11) so that SDLDraw() submits into the OpenXR game-quad swapchain. The per-frame entry point of xr_input.c is xr_input_sync_and_draw(), called from the frame path in xr_session.c on the engine's own thread.
  • The engine's own log output goes to logcat (patch 08); Java and native first-party code log under the tag QuestShock.
  • Bindings, config and menu state are owned natively. Java only displays and requests.
  • gScreenWide/gScreenHigh are only set by the engine's ChangeScreenSize(), so they are 0 until its first mode change. Code that runs during startup (such as qs_splash_show()) takes the screen size from xr_get_game_swapchain_size() instead.

4.4 Threads and cross-thread requests

  • Engine state (player struct, game loop, cheats, config apply) must only be touched on the engine thread.
  • Menu code calls native functions through JNI. Anything that changes engine state must be enqueued, not executed inline: the JNI export calls a qs_config_request_*() function, which pushes an SDL custom event (SDL_RegisterEvents); qs_config_handle_event() handles it on the engine thread (it is called from pump_events(), engine patch 21). To add a new request, add an event type, the qs_config_request_*() enqueue function and a branch in qs_config_handle_event().
  • Getters that Java calls (qs_config_cheat_unlimited_mask(), binding-name getters, ...) must be plain scalar or string reads that are safe from any thread.
  • Per-frame work that must run on the engine thread (such as qs_config_frame_tick()) is called from xr_input.c.
  • JNI symbol names must match the Java native declarations exactly (Java_de_ladkau_questshock_<Class>_<method>). A mismatch only fails at runtime, so check it (section 13).

4.5 Off-screen views (OverlayPanel, MenuOverlay, KeyboardOverlay)

The view tree is built and drawn from native code and is never attached to a window. Consequences that shape the UI code:

  • Only ACTION_DOWN/ACTION_UP touches arrive (the laser trigger). There is no drag, fling or multi-touch. Scrolling is done by a self-drawn scroll bar (ScrollBarView in MenuOverlay), not by the framework.
  • View.post() never runs, so anything that defers work through it does not work. ProgressBar and SeekBar are unusable for the same reason.
  • Hover and hit-testing use OverlayPanel.absoluteBounds(), which subtracts ancestor scroll offsets.
  • Toggling a child's visibility needs an explicit re-measure and re-layout (see relayoutAndRedraw()).
  • All user-visible text goes through Strings.get(key, language) with an English, French and German entry, and is re-applied in applyTranslations() when the language changes. "OpenGL" is a brand name and is not translated.

5. Configuration system

  • qs_config.c holds one parsed struct per group (video_options, general, player_position, player_controls), a parse function, an apply function, and the writer qs_config_write_file(). Groups are tagged live-appliable or restart-required (QS_*_REQUIRES_RESTART).
  • Load order at startup: questshock.config.json, else questshock.config.default.json (bundled, refreshed on every launch), else the engine's own prefs.txt behaviour. Save is only ever an explicit menu action.
  • Invalid values are logged and skipped individually; unknown keys are ignored.

Adding an option: parsed-struct field, parse, apply (with validation and a WARN on invalid input), write in qs_config_write_file(), restart flag, CONFIG.md, and (if it should have a shipped default) res/questshock.config.default.json.

The virtual screen has two profiles, "initial" (splash, cutscenes, main menu) and "game", switched by qs_screen_profile_notify_loop_mode() from engine patch 22. s_screen_profile_is_game in qs_config.c doubles as "a game is running" and gates the cheats and the menu pause.

6. Engine patches

Rules:

  • Guard every engine change with #ifdef __ANDROID__ so the desktop build is unaffected, and keep hunks minimal.
  • Patches are applied in filename order with patch -p1 from the engine root (paths look like a/src/GameSrc/hkeyfunc.c). Patch N is generated against the tree with patches 01..N-1 applied.
  • Some engine files use CRLF line endings (for example mouselook.c). Edits and generated patches must preserve them byte for byte; check with file or cat -A. An editor or script that rewrites line endings produces a whole-file diff.
  • Do not hand-edit hunk headers. Regenerate the patch from a scratch copy.

Creating or changing a patch:

  1. Make a scratch copy of engine/ (for example under the session scratch directory) and apply patches 01..N-1 to it (patch -p1 -d <scratch> < ...).
  2. Copy the target file(s) to a second location, edit them in the scratch copy.
  3. Produce the patch with diff -u using a/ and b/ path prefixes relative to the engine root, and save it as android/engine-patches/NN-android-<topic>.patch.
  4. Dry-run the whole series on a fresh copy of engine/ (patch -p1 --dry-run, or apply for real on the copy) and confirm every patch applies cleanly, with the patched file byte-identical to what you edited.
  5. Keep numbering contiguous, and update the README patch reference (5.4) and any code comments that cite patch numbers.

Gradle's stageEngine task re-stages and re-patches on build, but it can wrongly report UP-TO-DATE when a new patch file appears. If a native change does not show up on the device, tell the maintainer to run ./gradlew --stop, or android/force-rebuild.sh, or delete build/android-engine, before assuming the change is wrong.

Engine symbols used from first-party code (for example game_paused, pause_game_func, toggle_giveall_func, qs_cheat_refill, qs_in_cyberspace) are declared extern next to their use in qs_config.c or xr_input.c, with a comment naming the engine file and patch.

7. Controller mapping (xr_input.c)

Structure:

  • Physical controls are OpenXR actions, suggested once against three interaction profiles: Meta Touch, HTC Vive wand and the core khr/simple_controller. A single wrong path makes the whole xrSuggestInteractionProfileBindings call fail, so each profile lists only components it really has. A failure calls qs_config_report_fatal_input_error() and aborts input init.
  • The raw-input layer (QsRawInput) digitizes every physical control (buttons, triggers, squeezes, thumbstick clicks, and eight thumbstick directions with hysteresis) each frame. QS_RAW_NONE means unbound.
  • Actions (QsControlAction) are bound to raw inputs through s_binding[]. The default bindings are kDefaults[].
  • Contexts: ALWAYS, NORMAL (move, strafe, turn, jump, stand, crouch, prone) and CYBER (the eleven cyber_* actions). The active set is chosen with qs_in_cyberspace() (patch 24). Two actions conflict only if their contexts can be active together (xr_input_contexts_overlap()).
  • Capture mode (xr_input_process_capture()): the next fresh raw down-edge is assigned to the chosen action. Any other action in an overlapping context that held that input (or its symmetric alias) is set to none. The reset and the timeout (8 s) paths never leave a half-assigned state.
  • The menu button is not a raw input and can never be bound, so the menu can always be reached to press Reset to Defaults.
  • While the menu is open, mouse_click is always the trigger (the controller used to operate the menu); after it closes, the bound mouse_click drives both the keyboard and the game quad.
  • While the Controller Options tab is open, the menu's own thumbstick snap-turn and smooth-move are disabled (g_controller_options_tab_open, set through nativeSetControllerOptionsTabOpen).
  • Free view: holding a free_*_thumbstick_view binding turns that stick into the engine's mouse look (cursor forced hidden, stick direction bindings suspended). It uses SDL_ShowCursor(SDL_DISABLE) because the engine zeroes mouse-look velocity while the cursor is visible.
  • Cyberspace pitch and bank scale with stick deflection through the globals from patch 25 (qs_cyber_pitch_scale, qs_cyber_bank_scale).

Adding or changing a bindable action (checklist)

All of these lists share one order and must stay identical:

  1. QsControlAction enum in xr_input.c (and append before QS_ACTION_COUNT).
  2. kActionNames[] in xr_input.c.
  3. s_binding[] initial values and kDefaults[] in xr_input.c.
  4. If it injects a key: a kKeyActions[] entry (keycode, modifiers, whether it needs SDL_TEXTINPUT); add the key to the stuck-key watchdog list (kWatchedKeys) if it is held continuously.
  5. ACTION_KEYS in MenuOverlay.java, and FIRST_ROW_ACTIONS if it should be listed first.
  6. Strings.java: action_<key> in EN/FR/DE.
  7. QS_MAX_PLAYER_CONTROL_ACTIONS in qs_config.c if the count outgrows it.
  8. CONFIG.md: the action tables and the count in the Player Controls intro; README default-controls table if it has a default binding.
  9. Check that no two actions that can be active together share a default input.

Injecting keys into the engine

  • Plain printable keys (letters, digits, Space, punctuation) need an SDL_TEXTINPUT event plus SDL_KEYDOWN/SDL_KEYUP. Modifier, control-range, F and keypad keys use SDL_KEYDOWN/SDL_KEYUP with the right mod.
  • Send the release on the real up edge, never in the same call as the press. The engine polls movement keys once per frame before it drains events, so a press and release drained together are never seen and the key silently does nothing. Tie the release to the physical button's up edge (or the touch's ACTION_UP/ACTION_CANCEL).
  • Held keys are tracked in the engine's sshockKeyStates[]; xr_input.c keeps a watchdog that clears a watched key the engine still thinks is down after its binding is released.
  • Actions that share an engine key (for example the several ways to fire, or the movement keys) are handled with sibling logic in the dispatch loop (wantDown[]); do not press or release the shared key independently.

8. Cheats tab

  • Buttons and switches live in MenuOverlay.buildCheatsTab(); ids are QS_CHEAT_* in qs_config.c and CHEAT_* in MenuOverlay.java (keep them identical).
  • The engine's own cheat handlers (toggle_giveall_func, toggle_physics_func, toggle_up_level_func, toggle_down_level_func) are called on the engine thread through qs_apply_cheat(), and only while a game is running.
  • The four unlimited switches are a bitmask in qs_config.c (QS_CHEAT_MASK_*: health 1, energy 2, ammo 4, items 8). Every frame qs_config_frame_tick() calls qs_cheat_refill(mask) (patch 26). The mask values must match the patch. The switches are not persisted.
  • The four cheat_* hotkeys are deliberately not bindable in player_controls. New cheats go on the Cheats tab.

9. Menu pause

Opening the menu pauses a running game (qs_apply_menu_pause() through the menu-visibility event); closing it resumes only if the menu did the pausing and the game is still paused. Do not toggle pause blindly, since the pause function toggles.

10. Bundled files on the device

QuestShockActivity extracts these into /sdcard/QuestShock/:

  • shaders, the default soundfont and GET_ASSETS_QUEST.txt (once);
  • questshock.config.default.json and controller_profiles/ (every launch, overwriting only the bundled filenames so user-added profiles survive).

res/splash.png stays in the APK assets and is not extracted to the device; QuestShockActivity decodes it and hands the pixels to qs_splash.c.

res/controller_profiles/, res/splash.png and the default config are staged into APK assets by build-image/prepare-android-project.sh; every staged file or directory must also be declared as inputs.file(...)/inputs.dir(...) of stageEngine in android/app/build.gradle.

The proprietary game data (res/data/, res/sound/) is never bundled or committed. The app refuses to start the engine until it is present.

11. Signing

Distributed APKs are release builds signed with one fixed key, so users can update in place. build-image/build-apk.sh reads the key from QUESTSHOCK_KEYSTORE, QUESTSHOCK_KEYSTORE_PASSWORD, QUESTSHOCK_KEY_ALIAS and QUESTSHOCK_KEY_PASSWORD; android/app/build.gradle applies it to the release build type only when QUESTSHOCK_KEYSTORE is set. Both CI workflows decode it from the ANDROID_KEYSTORE_BASE64 secret. Without a key the build is a debug APK (...-debug.apk), which is only for testing. Never commit a keystore or a password, print them, or put them in a workflow file (README section 4.3 lists the secrets).

12. Code and documentation style

  • Match the surrounding code: naming, comment density, idiom. C is plain C with static helpers and // comments; Java targets Android API 34 and uses only the SDK plus androidx.core.
  • Keep functions and file-level constants near their use. Put magic numbers in named constants (see QS_FREE_VIEW_*, QS_CYBER_ROTATION_*).
  • Keep the xr_input.h and qs_config.h declarations documented with a short comment saying which thread may call them.
  • Comments: what and why, current state only (rule 5 in section 2).
  • Docs: README.md is organised by numbered sections that other text cites ("5.5.3"); do not renumber existing sections. Add new material as a new subsection instead.
  • English, French and German strings live in Strings.java; keep the three entries of each key on the same put(...).

13. Verifying a change without building

Because you may not build (rule 2), check statically and say so in the report:

  • Patches: apply the whole series to a fresh copy of engine/ and confirm no rejects. Confirm the edited file matches the patched result byte for byte.
  • JNI: every native method in the Java classes has a Java_de_ladkau_questshock_<Class>_<method> export with a matching signature, and no export is left without a Java declaration.
  • Ordered lists: QsControlAction, kActionNames[], s_binding[], kDefaults[] and ACTION_KEYS have the same length and order.
  • Strings: every Strings.get("...") key exists in Strings.java.
  • Balance: braces and parentheses balance. qs_config.c reports a brace-count offset of +5 from character literals containing '{'; that is not a bug.
  • Docs: README and CONFIG.md agree with the code (action counts, default bindings, patch list).

Then report what was checked, what was not (build, on-device behaviour), and any device check the maintainer should do (for example a new patch that may need android/force-rebuild.sh).

14. Commit messages

Draft only (rule 1). Use a short imperative subject (about 70 characters), a blank line, then a body that explains what the change does and why: the user-visible behaviour, the mechanism, and any constraint worth remembering. Mention new files that need git add.