ml ab2b778f44
build / build (push) Successful in 3m36s
Add AI-upscaled critter/object art as a new MDL mod type
Extends the MDL system beyond text overrides: bitmaps/<label>.json
names a model/scale and resource list, and mdlp extracts each
resource's frames, runs them through an ONNX Runtime CPU upscaler
baked into mdlp-image, re-quantizes to the game's palette, and writes
the result back - same build-time pipeline as message overrides.

Adds animgallery, a standalone viewer that renders any data
directory's animated resources as an HTML/GIF gallery with a
checkbox-driven MDL JSON generator, and -only-changed to preview just
what a mod overrides against the base game.

Fixes a real data race in hacked's ChangeNotifier.hashAll() (found via
go build -race causing non-deterministic resource counts), via a new
numbered patch rather than editing the vendored source.

Adds engine-patches/, a general-fixes counterpart to
android/engine-patches/ now staged and applied to the desktop builds
too (previously unpatched): a real GL_UNPACK_ROW_LENGTH stride bug, an
audio error-logging improvement, and two critter-rendering fixes -
mod art was losing to the base game's own re-opened resource files,
and critters rendered at the wrong size/position once that was fixed,
both from how the billboard renderer derives on-screen size and
placement straight from bitmap pixel dimensions.

Updates README.md and AGENTS.md to document engine-patches/.
2026-09-27 20:52:35 +02:00

Questshock

questshock

An open source project to play the classic 1994 System Shock on a VR headset, built on top of Shockolate, a cross-platform port of the original game.

The game is played on a virtual screen: the engine renders its usual flat 2D frame, and Questshock shows it as a large screen floating in front of you in VR. It is not a stereoscopic 3D conversion; you sit or stand in front of the screen the way you would in front of a monitor or a cinema screen. The screen's width and distance can be set separately for the menus/intro and for gameplay. Both controllers act as laser pointers for the game's mouse-driven interface and for a VR menu and on-screen keyboard, and every controller button, stick direction and game hotkey can be assigned to a game action. Questshock runs entirely on the headset (tested on a Meta Quest 2) as a sideloaded Android app, and needs your own copy of the game data.

Unofficial fan project. Questshock is not affiliated with, endorsed by or supported by the rights holders of System Shock. It contains no game data: you need your own copy of System Shock: Enhanced Edition.

Screenshots

Exploring the station on the virtual screen
Exploring the station on the virtual screen
Reading an email - the laser pointer clicks like a mouse
Reading an email - the laser pointer clicks like a mouse
Cyberspace
Cyberspace
The game screen, the menu and the keyboard float independently
The game screen, the menu and the keyboard float independently
The VR menu
The VR menu
Assigning controller inputs to game actions
Assigning controller inputs to game actions

A short video shows the game in use.

Quick start

You need a Meta Quest (tested on a Quest 2) and your own copy of the game data.

  1. Get System Shock: Enhanced Edition (for example from gog.com); see "3. Game assets" below.
  2. Download questshock-<version>-android-arm64.apk from the repository's Releases page and sideload it onto the headset with SideQuest or adb install.
  3. Launch it once. It creates /sdcard/QuestShock/. Copy your game's res/data/ and res/sound/ folders into /sdcard/QuestShock/res/.
  4. Launch it again. The left controller's menu button opens the menu; the default controls are in "5.1.1. Menu, controls and cheats" below.

Section "5.1. Installing and playing" has the details.

1. Design principles

  • Standalone-headset-only. The game must run entirely on the headset itself - no PC required, whether tethered or streamed (not PCVR). Any feature or dependency that assumes a host PC is out of scope.
  • OpenXR-first. Favor OpenXR-standard APIs over vendor-specific ones (e.g. Meta's Oculus Mobile SDK) wherever there's a choice, so the port isn't locked to Meta Quest and can work across other standalone, Android-based OpenXR headsets (e.g. Pico) too.

2. Layout

  • engine/ - a vendored snapshot of the Shockolate engine source. Built via Docker; see "4. Desktop build" below. engine-patches/ holds general (not Android-specific) fixes applied to a scratch copy at build time - same convention as android/engine-patches/ below, but for the Linux/Windows desktop builds; engine/ itself is never modified. See "4.4. Engine patch reference" below for what each one does.
  • build-image/ - the Dockerfile (and supporting scripts) for the engine build environment. Every third-party dependency the engine needs to compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI soundfont) is fetched and built once into this image - compiling engine/ itself needs no network access.
  • build-image.sh / run-image.sh / upload-image.sh - build the image, run it to compile the engine, and push it to a registry, respectively.
  • res/assets/ - where you place your own purchased copy of the game (see "3. Game assets" below); res/assets/extract_assets.sh extracts it into ss_ee/.
  • res/run.sh / res/run.bat - the launcher scripts, copied into dist/dist-win on build. res/run-mods.sh / res/run-mods.bat are companion launchers that additionally load every mod built under the modding/ subproject's own dist/mods/ (see modding/README.md section 4.3) - copied into dist/dist-win the same way, but not into the redistributable package/package-win archives, since that build output is local and git-ignored.
  • res/questshock.config.default.json - the bundled default configuration (see CONFIG.md); res/controller_profiles/ - the per-controller illustrations and labels shown in the Controller Options tab (see CONFIG.md). Both are copied into the APK, as is res/splash.png, the startup splash image. res/logo.png is the 1024x1024 logo, the master of the launcher icon (android/app/src/main/res/mipmap-*/ic_launcher.png are scaled copies of it) and the image to use for store listings; res/logo_small.png is the copy shown at the top of this file.
  • Makefile - targets:
    • all (default) - alias for dist.
    • build-image - builds the Docker build-image (./build-image.sh). Only needed once, or after build-image/ changes.
    • engine - compiles engine/ (the vendored Shockolate snapshot) via the build-image, offline. Always re-run by the targets below, so dist/package/apk stay in sync with the current engine/ source.
    • assets - preflight check only: fails with a pointer to res/assets/extract_assets.sh if the purchased game assets (see "3. Game assets" below) haven't been extracted yet.
    • dist - assembles dist/, a self-contained runnable copy of the game, out of the compiled engine and the extracted assets (depends on engine + assets). See "4. Desktop build" below.
    • package - builds a versioned, redistributable dist/shockolate-<version>-linux-<arch>.tar.gz that omits the proprietary game assets (ships res/GET_ASSETS.txt in their place). See "4.1. Packaging a distributable build" below.
    • apk - builds dist/questshock-<version>-android-arm64.apk for Android-based OpenXR headsets (a debug build, ...-arm64-debug.apk, when there is no signing key - see "4.3. Releases and signing" below). Unlike every other target, this needs network access at build time (Gradle/AGP's own dependency resolution). See "5. Android / Quest build" below.
    • android-studio - stages engine//android/gl4es-src/ (scratch, patched copies) and the Android prebuilts with host-resolvable paths, so Android Studio can compile and deploy android/ natively instead of inside the build-image. See "5.2. Building natively in Android Studio" below.
    • clean - removes all build output (dist/, build/, compiled engine artifacts, and the staged Android project files).
  • android/ - the Quest app (Java SDLActivity glue, Gradle project). android/engine-patches/ holds the patches needed to build engine/ as an Android shared library instead of a desktop executable - applied to a scratch copy at build time; engine/ itself is never modified. android/gl4es-src/ similarly vendors GL4ES (see "6. License" below), with android/gl4es-patches/ applied to a scratch copy at build time (same as engine/) and compiled from source alongside it, not prebuilt. See "5. Android / Quest build" below for what each patch does.

3. Game assets

System Shock's game data is not included in this repository and cannot be redistributed - you need to own a copy. Buy System Shock: Enhanced Edition on gog.com, download the offline installer (a .exe), and drop it into res/assets/. Then run res/assets/extract_assets.sh, which pulls the classic game's data and sound files out of the installer (it's an Inno Setup package; the actual game data lives inside it in a zip-format sshock.kpf) into res/assets/ss_ee/. That script needs innoextract and unzip; if they aren't installed locally it falls back to running the extraction in a throwaway Docker container instead.

If you already have the game installed instead (on Windows, or via Wine/Proton on Linux), you don't need the installer or the script at all - just copy its res/data/ and res/sound/ folders directly into res/assets/ss_ee/data/ and res/assets/ss_ee/sound/. That's exactly the same layout extract_assets.sh produces, so make dist/make package/make apk pick it up the same way either way. This one copy of game assets feeds both the desktop build and the Android/Quest build below.

Note on French/German: the GOG Enhanced Edition installer only ships English MFD (Multi-Function Display - the small in-game screens showing the map, inventory, etc.) art resources (mfdart.res) - it doesn't include mfdfrn.res/mfdger.res, which French/German need in addition to their own text resources (frnstrng.res/gerstrng.res, which the installer does include). Without those two files, switching to French/German (via questshock.config.json's general.language or the in-headset menu's Language row - see CONFIG.md) is refused with an in-headset popup rather than applied, since doing so anyway used to crash the game. If you also own System Shock: Classic on gog.com (which does include both files), drop its offline installer into res/assets/ alongside the Enhanced Edition one and extract_assets.sh pulls them out of it too, entirely optionally - it still runs fine with just the Enhanced Edition installer.

The intro cutscene follows the selected language too, when the matching voice-over file (svfrintr.res/svgeintr.res under res/data/) is present; otherwise the English intro plays.

4. Desktop build

Builds and runs Questshock natively on the desktop (your dev machine, or any Linux/Windows box) - useful for local development and testing without a VR headset at all. For the VR-headset build, see "5. Android / Quest build" below instead.

Two platforms: Linux (native) and Windows (x86_64, cross-compiled via MinGW - see "4.2. Windows cross-build" below for how). make dist/make package build both by default; use make dist-linux/make dist-win (or package-linux/package-win) to build just one.

Prerequisites:

  • Docker - build-image.sh/run-image.sh build and run the engine build-image; no other build tool touches the host directly. The Windows cross-toolchain (MinGW) is baked into the same build-image, so building dist-win needs nothing extra beyond Docker itself.
  • innoextract and unzip - optional, only needed by res/assets/extract_assets.sh (see "3. Game assets" above) to unpack the GOG installer locally; if either is missing, that script falls back to running the extraction inside a throwaway Docker container instead.
  • zip - only needed on the host for make package/package-win (the Windows release archive); make dist/dist-win and package-linux don't need it.
# 1. Build the engine build-image (once, or after build-image/ changes)
./build-image.sh

# 2. Get your own copy of the game data (see "3. Game assets" above), then:
res/assets/extract_assets.sh

# 3. Compile the engine and assemble dist/ (Linux) and dist-win/ (Windows)
make dist

# 4. Play
dist/run.sh                    # Linux
dist-win/run.bat                # Windows (or systemshock.exe directly)

make dist always recompiles the engine from the current engine/ source (via run-image.sh), so a fresh build-image plus a re-run of make dist is all that's needed after pulling engine changes.

4.1. Packaging a distributable build

make package builds both dist/shockolate-<version>-linux-<arch>.tar.gz and dist/shockolate-<version>-windows-x86_64.zip: the compiled binary/DLLs, shaders, a default MIDI soundfont, license information, and res/GET_ASSETS.txt in place of the actual game data (which neither archive ever includes). Version comes from the current git tag (push a vX.Y.Z tag to drive a release); without one it builds an untagged 0.0.0-dev+<sha> placeholder. make apk (see "5. Android / Quest build" below) is versioned identically, via the same build-image/version.sh.

4.2. Windows cross-build

make dist-win/make package-win cross-compile a native Windows x86_64 build via MinGW (x86_64-w64-mingw32-gcc/g++, baked into the build-image alongside the Linux toolchain - see build-image/build-engine-win.sh and the "Windows cross-compile" section of build-image/Dockerfile) - no Windows machine, Wine, or VM involved in building it. engine/CMakeLists.txt already has working WIN32/ MINGW branches from Shockolate's own upstream Windows build (built natively via Git Bash/MinGW on a real Windows machine - see engine/build_win64.sh/engine/appveyor.yml) - this cross-compiles those same branches offline and reproducibly, from Linux, via Docker. It applies the same engine-patches/ series the Linux build does (section 4.4); unlike the Quest build's android/engine-patches/, no Windows-specific source patching is needed on top of that.

dist-win//the packaged .zip ship systemshock.exe with its DLLs (SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime) sitting flat alongside it, rather than in a lib/ subdirectory like dist/ - Windows' default DLL search order already checks the executable's own directory first, so no PATH/library-path setup is needed the way dist/run.sh needs LD_LIBRARY_PATH.

Confirmed working (and playable) on a real Windows machine.

A Gitea Actions workflow (.gitea/workflows/build.yml) builds this package on every push, using the build-image as its container (so no extra setup is needed in CI beyond the image itself), and publishes the resulting tarball to dl.ladkau.de.

4.3. Releases and signing

The repository is developed on Gitea, and the GitHub repository is a mirror of it. .github/workflows/release.yml is GitHub's own workflow (the Gitea build is .gitea/workflows/build.yml):

  • Pushing a tag vX.Y.Z builds the Linux and Windows packages and the Quest APK inside the build-image and attaches them to a GitHub release with the same name. A manual run ("Run workflow") builds the same files without releasing them and keeps them as a downloadable workflow artifact.
  • The build-image is pushed to GitHub's container registry (ghcr.io) when it does not exist there yet. Its tag is a hash of build-image/Dockerfile, docker-entrypoint.sh and build-engine.sh, so a change to any of them builds a new image (build-image/VERSION is not used). The first run is slow because it builds the image.
  • The APK is signed with the release key described below.

Signing. Android only updates an installed app from an APK signed with the same key, so every distributed APK is signed with one fixed key. make apk (build-image/build-apk.sh) builds and signs the release APK when QUESTSHOCK_KEYSTORE is set, together with QUESTSHOCK_KEYSTORE_PASSWORD and optionally QUESTSHOCK_KEY_ALIAS (default questshock) and QUESTSHOCK_KEY_PASSWORD (default: the keystore password). Without a keystore it builds a debug APK, questshock-<version>-android-arm64-debug.apk, signed with a per-machine debug key. That is what Android Studio's Run uses too; an installed build has to be uninstalled before one signed with another key can be installed.

Create the key once and keep the file and its password safe: without them, users have to uninstall the app to get an update.

keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \
    -alias questshock -keyalg RSA -keysize 4096 -validity 10000
base64 -w0 questshock-release.jks    # the value of ANDROID_KEYSTORE_BASE64

Both CI systems read the key from these secrets: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, and optionally ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD. Without ANDROID_KEYSTORE_BASE64, the Gitea build produces a debug APK and the GitHub build fails on a tag. *.jks and *.keystore are git-ignored.

4.4. Engine patch reference

engine-patches/ (applied to a scratch copy of engine/ by build-image/build-engine.sh and build-engine-win.sh) is a plain, numbered patch series, same convention as the Quest build's android/engine-patches/ (section 5.4) - engine/ itself is never modified. It only carries fixes that are general (not Android-specific); anything Quest/OpenXR-only belongs in android/engine-patches/ instead, which the desktop build does not apply.

  • 01-desktop-texture-row-length.patch - opengl_cache_texture()/ set_texture() (OpenGL.cc) passed the original 8-bit bitmap's own row stride (bm->row) to GL_UNPACK_ROW_LENGTH, describing the wrong layout to OpenGL for the actually-uploaded buffer (a separately allocated RGBA32 conversion, converted->pixels) whenever the two strides differ - corrupting the texture upload. Uses converted->pitch / 4 instead.
  • 02-desktop-audio-error-detail.patch - logs SDL_GetError() alongside "Couldn't open audio device" instead of just the bare message.
  • 03-desktop-fix-mod-resource-priority.patch - LoadModFiles() (Modding.c) registers mod resource files once at startup, but two places re-open the base game's own object/texture art files afterward - and opening a resource file re-registers every ID it contains, unconditionally, with no check for whether a mod already claimed it (see ResReadDirEntries() in resfile.c). That silently reverted any mod override for that art back to the base game's own graphics, twice over: obj_set_secondary_properties() (objsim.c) re-opens objart2.res/ objart3.res transiently, just to read frame-count metadata; later in the same startup sequence, load_dynamic_memory() (cybmem.c) re-opens texture.res/handart.res/objart2.res/objart3.res again, this time persistently - these are the handles actual gameplay renders from, so this second point is what mattered for the mod override actually showing up in-game. Fixed by re-running LoadModFiles() right after each place's re-opens, so mods regain priority. Re-registering a mod file's resource IDs means actually reopening it (there's no "just re-register" primitive), so LoadModFiles() now closes each mod file's previous handle first - one open handle per mod file at a time, rather than leaking a fresh file slot on every repeat call (a new game or a loaded save can both re-run this whole sequence).
  • 04-desktop-fix-critter-sprite-scale.patch - critters render as camera-facing 2D billboards (gameobj.c's FAUBJ_CRIT case), not UV-mapped 3D polygons, and their on-screen size comes directly from the bitmap's own pixel width/height (Bitmap.c's g3_bitmap_common(), via _g3d_bitmap_u_anchor/_g3d_bitmap_v_anchor and bm_w/bm_h) - so a mod that replaces a critter's art with a higher-resolution bitmap for more detail made the critter itself render bigger, proportionally to the resolution increase. Fixed by caching every critter view's base-game bitmap size (objsim.c's cache_critter_native_size()/ get_critter_native_size(), keyed by resource ID) while objart2.res/objart3.res's base-game data is still the active resolver in obj_set_secondary_properties() (before patch 03's LoadModFiles() call hands priority to a mod), then using that cached size instead of the bitmap's own (possibly modded) dimensions when computing the sprite's on-screen extent - threaded through _fr_draw_bitmap() and g3_light_anchor_bitmap()'s new native_w/ native_h parameters, consumed and reset by g3_bitmap_common() so an unrelated bitmap draw never inherits a stale override. A mod's higher resolution now only changes visible detail, not the object's size. This depends on the mod's bitmap keeping a real anchor point in its own Header.Area, in the original, un-upscaled unit space (see modding/src/mdlp/upscale.go's imageToBitmap()) - _fr_draw_bitmap() only forwards native_w/native_h down the anchor-aware rendering path when given a positive anchor, so a zeroed-out one silently skips this patch's override entirely, and an anchor scaled to the mod's higher resolution instead of left in the original unit space visibly offsets the sprite from where it should sit, since it shares its coordinate system with this patch's own size override rather than with the bitmap's real pixel dimensions.

5. Android / Quest build

Prerequisites:

  • Docker - same build-image as the desktop build (see "4. Desktop build" above), plus network access at build time for Gradle/AGP's own dependency resolution (the one target that isn't fully offline).
  • SideQuest or adb (Android Platform Tools) - to sideload the built APK onto the headset, and to enable/verify USB debugging; see "5.1. Installing and playing" below.
  • Building/debugging natively in Android Studio instead of via make apk needs its own separate toolchain - see "5.2. Building natively in Android Studio" below.

make apk builds dist/questshock-<version>-android-arm64.apk (or ...-arm64-debug.apk, see "4.3. Releases and signing" above) - an immersive OpenXR app (see android/app/src/main/cpp/xr_session.c) that can be sideloaded onto any Android-based VR headset with OpenXR support (Meta Quest, Pico, etc. - see "1. Design principles" above), not just one vendor's store. The game's own rendering is unchanged (still a flat, 2D render, no stereo 3D scene), but instead of running as a Home-hosted 2D panel it's now shown as a single head-tracked quad floating in front of the viewer, with a separate menu quad toggled by a controller button and driven by a laser-pointer-style aim ray from each hand (android/app/src/main/cpp/xr_input.c) - point and pull the trigger to interact with it, same as the game's own Bluetooth mouse/keyboard input otherwise works unchanged.

The menu (MenuOverlay.java, described in "5.1.1. Menu, controls and cheats" below) and the keyboard (KeyboardOverlay.java) are two fully independent quads, each its own XrOverlay instance (android/app/src/main/cpp/xr_overlay.c - a generic "off-screen Android View rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by both) with its own swapchain, visibility, and position, so they can be shown/hidden/moved independently - the keyboard can stay open while also picking something from the menu. The controller's menu button only ever opens/closes the menu; the menu's "Keyboard" button only opens the keyboard (never touching the menu's own visibility) - the keyboard's own title bar holds the only way to close it again.

The keyboard is a hand-built, full US-layout key grid (there's no system IME to borrow once immersive) that forwards each key press straight to the game as real input - non-printable keys (Esc/Enter/Backspace/Tab/arrows/ F1-F12) as a SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a physical Bluetooth keyboard's presses already go through, and printable keys (letters, digits, punctuation, Space) via a synthesized SDL_TEXTINPUT event pushed directly from native code (questshock_native.c). A Shift key toggles the whole grid between lowercase/numbers and uppercase/symbols (doubling as Caps Lock - it stays toggled until pressed again), and Ctrl/Alt arm as one-shot modifiers, consumed by whichever key is pressed next - meant as a general stand-in for keyboard-driven functionality that isn't (yet, or ever) mapped onto the controllers, not just a future config screen's text entry.

Once opened, the keyboard stays up as a standing input panel while actually playing rather than a modal you open and close. Its title bar doubles as a drag handle (grab-and-drag with the trigger, handled entirely on the native side in xr_input.c before it ever reaches KeyboardOverlay's own touch dispatch) for repositioning it, and holds the Close button that hides it. It's also rendered semi-transparent (XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT in xr_session.c, set only on the keyboard's layer - the menu launcher stays fully opaque) so whatever's behind it - the game quad, mid-play - stays visible while it's up.

While the menu launcher or keyboard is open, each hand also gets a thin, colored laser-beam quad (cyan left, amber right) from the controller to wherever its ray currently crosses that panel's plane - a second, billboarded XrCompositionLayerQuad per hand (xr_input_build_beam() in xr_input.c), oriented so it reads as a line from the viewer's eye regardless of angle, since the flat cross-shaped reticle used while aiming at the game quad is drawn directly into the game quad's own texture and can't represent a ray traversing real 3D space. Aiming at the game quad itself still uses that flat reticle, unchanged. (Only tested on Meta Quest so far - the steps below use Quest-specific tool names where relevant, but the same adb install flow applies to any Android headset with USB debugging enabled.)

5.1. Installing and playing

  1. Install the APK with SideQuest (or adb install).
  2. Launch it once. It'll ask for storage permission, then create /sdcard/QuestShock/ and extract its own bundled files (shaders, a default MIDI soundfont) there - res/data/ and res/sound/ are deliberately left missing, since that's the proprietary game data.
  3. With the headset connected to a PC, use SideQuest's file browser (or any MTP file manager) to copy your own res/data/ and res/sound/ (see /sdcard/QuestShock/GET_ASSETS_QUEST.txt, extracted in step 2, for exactly what's needed and where it comes from) into /sdcard/QuestShock/res/.
  4. Launch it again.

/sdcard/QuestShock/ also holds an optional persistent settings file, questshock.config.json, loaded automatically at startup and saved/loaded from the in-headset menu - see CONFIG.md for the documented options. Every launch also refreshes questshock.config.default.json (the bundled defaults, used when there is no questshock.config.json) and controller_profiles/ (controller illustrations for the Controller Options tab) there; your own saved config and any extra profiles you add are left alone.

5.1.1. Menu, controls and cheats

At startup the QuestShock title (res/splash.png) fades in and out on the virtual screen for a few seconds, before the game's own intro screens.

The controller's menu button (left controller) opens and closes the menu. It can't be reassigned. Opening it pauses a running game; closing it resumes the game, unless it was already paused. Aim with either hand's ray and pull the trigger to press buttons. The menu has four tabs:

  • Main Menu - Keyboard, Save/Load Configuration, Reset Position and the language.
  • Video Options - renderer, resolution, and the width and distance of the virtual screen for the initial screen (splash, intro, main menu) and the game screen.
  • Controller Options - shows the active controller with labelled inputs, and one button per action to assign it to a controller input (tap the button, then press the input). Assigning an input that another action already uses unbinds that other action. Reset to Defaults restores the default bindings.
  • Cheats - the engine's cheats as buttons (give all items and weapons, toggle physics, next and previous level) and four independent switches for unlimited health, energy, ammo and items. The switches last until the app restarts and are not saved. They only work while a game is running.

Default controls (all assignable, see CONFIG.md):

Input On foot In cyberspace
Left stick Move forward/back, strafe Pitch up/down, bank left/right
Right stick left/right Turn Roll
Right stick up Stand Thrust
Right stick down Prone -
Left stick click Jump -
Right stick click Automap -
Right A / B Click / double-click Click / double-click
Right trigger Fire weapon Fire weapon
Left grip (hold) Free view with the left stick Free view with the left stick

Every game hotkey (weapon select, MFD panels, save/load, ...) can also be assigned to a controller input; they are unbound by default.

5.2. Building natively in Android Studio

make apk always compiles the engine and links the APK inside the Docker build-image - convenient for CI/CLI builds, but Android Studio can't attach a debugger to (or get IDE code-intelligence for) a build that happens inside a container it isn't running.

To have Android Studio compile and deploy android/ itself instead:

make android-studio

(equivalent to ./run-image.sh bash build-image/prepare-android-project.sh --host-paths directly.) This stages everything make apk normally stages (scratch, patched copies of engine/ and android/gl4es-src/; the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the same as build-apk.sh's own prep step - except it writes android/engine.properties with paths that resolve on your host filesystem, and additionally exports the prebuilt libraries (otherwise only present inside the image, at /opt/prebuilt/android) to build/android-prebuilt/ so they're visible outside the container too. gl4es itself is compiled from its scratch copy as part of Android Studio's own native build (a CMake subdirectory of engine/'s build, not a separate prebuilt step), so android/gl4es-src//android/gl4es-patches/ changes are picked up by a normal rebuild here - no need to re-run ./build-image.sh first, unlike build-image/Dockerfile changes. Re-run make android-studio whenever engine/, android/engine-patches/, android/gl4es-src/, android/gl4es-patches/, or the prebuilt-library versions in build-image/Dockerfile change (the Gradle build already does this automatically for you via its stageEngine task, so this manual re-run is mainly useful for confirming staging succeeded on its own) - and always after make clean, which deletes android/engine.properties along with everything else.

Then, in Android Studio, use File > Open and select the android/ directory itself (the one containing settings.gradle - not the repo root, and not android/app/) to open it as a project (JDK 17, NDK 26.1.10909125, and SDK Platform/Build-Tools 34 installed, matching build-image/Dockerfile and android/app/build.gradle) and build/run normally - no Docker involved for this part.

5.3. Testing cycles in Android Studio

Once the project above is open, iterating against a real headset works the same as any other Android Studio project:

  1. Enable Developer Mode on the headset (via its companion phone app - for Quest, the Meta Horizon app's Devices/Developer Mode toggle) and turn on USB debugging once prompted. Connect the headset to your development machine with a USB cable (Wi-Fi debugging via adb connect works too, once paired once over USB).
  2. Confirm it's visible with adb devices - it should also show up in Android Studio's device dropdown.
  3. Press Run (or Debug, to attach breakpoints in both Java and, via Android Studio's "Dual"/"Native" debugger, the C code under android/app/src/main/cpp/ and the staged engine/ sources) - Android Studio builds, installs, and launches the app on the headset directly. No manual adb install or SideQuest step needed for this loop.
  4. /sdcard/QuestShock/ (game assets, extracted shaders/soundfont) persists across reinstalls from Android Studio, so this loop doesn't require re-copying res/data//res/sound/ on every iteration - only a full uninstall or wiping that directory clears it.
  5. Use Android Studio's Logcat pane to watch output live; both the Java side and the engine's own logging (see android/engine-patches/08-android-logcat-output.patch) are tagged QuestShock, so filtering Logcat by that tag shows everything questshock-specific without gl4es/OpenXR loader/system noise (drop the filter to see those too).
  6. Only re-run make android-studio manually (see above) after changing engine/, android/engine-patches/, android/gl4es-src/, android/gl4es-patches/, or the prebuilt-library versions in build-image/Dockerfile - the Gradle build's stageEngine task does this automatically otherwise, so plain Java/C++ edits under android/app/src/main/ just need another Run/Debug press.

If a native change genuinely doesn't seem to take effect even after a normal rebuild - stageEngine can wrongly report UP-TO-DATE for a newly-added file under android/engine-patches/ - run android/force-rebuild.sh and rebuild. For more visibility than Android Studio's collapsed Build panel, run ./gradlew :app:assembleDebug --console=plain from a terminal instead.

5.4. Engine/gl4es patch reference

android/engine-patches/ (applied to a scratch copy of engine/) and android/gl4es-patches/ (applied to a scratch copy of android/gl4es-src/) are both plain, numbered patch files applied in order at build time - engine/ and android/gl4es-src/ themselves are never modified. What each one does:

android/engine-patches/:

  • 01-android-shared-lib.patch - Android has no standalone executables (Java loads a shared library via JNI), so this builds CMakeLists.txt's main target as a SHARED library on Android instead of the desktop systemshock executable, folding in the Android-native glue sources (ANDROID_EXTRA_SOURCES).
  • 02-android-opengl-es.patch - replaces the desktop find_package(OpenGL) with add_subdirectory()-building gl4es from source (translates the engine's desktop GL calls to GLES/EGL), so it rebuilds incrementally alongside the engine instead of needing a full build-image rebuild per gl4es change.
  • 03-android-gles-context.patch - requests a GLES 3.2 context instead of a desktop GL core profile (Quest's Adreno GPU supports it, and 3.2 has GL_UNPACK_ROW_LENGTH/GL_CLAMP_TO_BORDER as core, avoiding workarounds needed on GLES 2.0).
  • 04-android-opengl-es-render.patch - swaps a few gl4es rough edges (its shader-rewrite-based glPointSize/alpha-test/point-sprite emulation) for real GLES-native equivalents: a custom pointSize uniform, a shader-side alpha discard, and native point-sprite rasterization.
  • 05-android-audio-driver.patch - forces SDL2's OpenSL ES audio backend instead of AAudio via SDL_SetHintWithPriority(..., SDL_HINT_OVERRIDE) (so nothing else can take precedence and silently leave AAudio selected), and logs the actual SDL_GetCurrentAudioDriver() result right after SDL_Init() as a standing on-device diagnostic. Does not by itself fix SFX/MIDI playback - see 16-android-audio-single-device.patch and README 5.5.3 for the actual cause and fix.
  • 06-android-resize-event.patch - makes the engine react to SDL_WINDOWEVENT_RESIZED, not just SIZE_CHANGED, since Android's SDL backend only ever sends the former for surface-driven resizes.
  • 07-android-logcat-link.patch - links Android's log library.
  • 08-android-logcat-output.patch - routes the engine's own log.c (INFO/DEBUG/WARN/ERROR) through __android_log_vprint so it reaches logcat, instead of stdio (which Android never captures).
  • 09-android-no-window-resize.patch - skips the desktop-only SDL_SetWindowFullscreen/SetWindowSize/SetWindowPosition calls on Android, since there's no real desktop-style window to resize and calling them desyncs SDL's cached window size from the real surface. Also calls opengl_resize() directly in ChangeScreenSize() (see README 5.5.6) - skipping SDL_SetWindowSize() means no native resize event ever fires on Android to trigger it when the in-game resolution changes, so it would otherwise never re-run there.
  • 10-android-openxr-cmake.patch - wires up the OpenXR loader plus EGL as link/include dependencies for the CMake build.
  • 11-android-openxr-present.patch - the core OpenXR present-path hook: redirects SDLDraw()/opengl_swap_and_restore() to submit into the XR swapchain (via xr_frame_begin()/xr_frame_end()) instead of the normal window, for both the GL-rendered 3D path and the plain software-composited path (splash screen/cutscenes/menus). Also logs once per transition between the two (INFO("Game rendering active: ...")) for on-device logcat diagnostics.
  • 12-android-texture-row-length.patch - OpenGL.cc's wall/floor texture upload (opengl_cache_texture()/set_texture(), only reachable when gShockPrefs.doUseOpenGL is true - it defaults false in Prefs.c, so this path is dormant on Quest by default) sets GL_UNPACK_ROW_LENGTH from the original 8-bit source bitmap's row stride (bm->row), but the buffer actually uploaded is a separately- allocated RGBA32 conversion of it with its own, unrelated stride. Fixed to use the converted buffer's own pitch instead.
  • 13-android-swapchain-sync.patch - android_draw_surface_as_quad() used glFlush() after drawing into the swapchain's FBO, which queues the GPU work without waiting for it to finish before xr_swapchain_release() hands the image to the OpenXR runtime for cross-process consumption. Changed to glFinish(), which blocks until the write has landed. The same gap existed in xr_overlay.c's xr_overlay_render_and_build_layer() (the keyboard/menu overlay blit path) and was fixed there directly, no patch needed since that file is first-party.
  • 14-android-quad-unpack-state.patch - fixes the diagonal-banded/ skewed corruption in the 3D game view during real gameplay (see README 5.5.2). android_draw_surface_as_quad()'s glTexImage2D upload of the tightly-packed uiRgba buffer relied on GL's default unpack state (GL_UNPACK_ROW_LENGTH/GL_UNPACK_ALIGNMENT at 0/4) instead of setting it explicitly. gl4es implements both client-side and persists whatever was last set across draw calls in the same context, so this upload inherited non-default unpack state left behind elsewhere in the frame and misread every row. Fixed by resetting both to the packed defaults immediately before this upload.
  • 15-android-audio-error-detail.patch - snd_start_digital()'s Mix_OpenAudio() failure only logged a fixed string, not the actual reason. Adds SDL_GetError() to the log line so the next on-device failure reports the real underlying error.
  • 16-android-audio-single-device.patch - fixes SFX/MIDI music not playing (see README 5.5.3). snd_start_digital() opened two SDL audio devices: a raw SDL_OpenAudioDevice() for cutscene/audiolog audio and a second one via Mix_OpenAudio() for SFX/MIDI - SDL's Android audio backends only ever allow one open non-capture device, so the second open always failed. Removes the raw device entirely and feeds cutscene/ audiolog PCM into the Mix_OpenAudio() device's output instead, via Mix_SetPostMix() (mixing in rather than overwriting, so it doesn't silence any SFX/MIDI music playing at the same time). cutsloop.c's and audiolog.c's SDL_PauseAudioDevice() calls (no longer meaningful without a dedicated device) are removed; the cutscene-audio-stream pointer swaps they guarded are now protected by SDL_LockAudio()/ SDL_UnlockAudio() instead, since SDL_mixer's audio thread now reads that pointer continuously via the post-mix hook.
  • 17-android-native-midi-unsupported.patch - NativeMidiInit() in MusicDevice.c has real implementations only under #ifdef WIN32 and #elif defined(USE_ALSA); on every other platform (Android included) it fell through to return 0 (success) without ever opening a device, so a midi-backend preference of "Native" (see README 5.5.4) played silently forever instead of falling back. Adds an #else branch that returns failure and logs a WARN, so InitDecXMI()'s existing fallback-to-dummy-driver logic actually triggers.
  • 18-android-opengl-immediate-mode-attribs.patch - fixes a black (and, after this patch's first revision, flat-colored/untextured) screen when switching to the OpenGL/3D render mode in-game (see README 5.5.5). Every glBegin(GL_TRIANGLE_STRIP)-based draw in OpenGL.cc - opengl_swap_and_restore()'s screen-composite quad, opengl_light_tmap() (every textured wall/floor polygon, via draw_vertex()), opengl_bitmap() (sprites), and opengl_draw_poly() (flat/gouraud polygons, also via draw_vertex()) - used glVertexAttrib2f/glVertexAttrib1f/glVertex3f immediate mode to set per-vertex texcoords/light, which gl4es only captures per-vertex for attribute 0 (position); custom attributes end up constant for the whole draw call. Adds android_imm_begin()/android_imm_vertex()/ android_imm_end(), a small shared immediate-mode-emulation shim that buffers vertices and flushes them as one real glDrawArrays() call, and switches every one of those call sites to it, Android-only. The starfield (opengl_begin_stars()/opengl_draw_star()/ opengl_end_stars(), GL_POINTS rather than GL_TRIANGLE_STRIP, up to NUM_STARS=1000 per frame) had the same bug for its per-star brightness and gets its own similarly-shaped android_stars_begin()/ android_stars_vertex()/android_stars_end() buffer. Also skips the unconditional SDL_GL_MakeCurrent(window, context) call every rendering function in this file had at its top, Android-only - a worthwhile cleanup on its own merits, but not the source of the persistent per-frame GL_INVALID_OPERATION (1282) logcat spam during 3D gameplay that this was first suspected to be (see README 5.5.8 for that bug's actual root cause and fix, in patch 20 below).
  • 19-android-fix-hackcam-opengl-frame.patch - fr_start_view()/ fr_send_view() (frsetup.c) called opengl_start_frame()/ opengl_end_frame() again for hack-camera security-monitor renders (which target a private software bitmap, not the GL-backed screen), since should_opengl_swap() doesn't exclude them the way use_opengl() already does. Adds the same exclusion to both call sites (see README 5.5.8).
  • 20-android-no-default-framebuffer.patch - the actual fix for the persistent per-frame GL_INVALID_OPERATION (1282) in README 5.5.8: opengl_end_frame()'s BindFrameBuffer(nullptr) bind to framebuffer/ renderbuffer id 0 has no valid target on Android (real presentation goes through the OpenXR swapchain's own FBOs, set up separately in xr_swapchain.c) and is also functionally dead there regardless - skipped entirely, Android-only.
  • 21-android-config.patch - hooks up the persistent JSON config file (see CONFIG.md, qs_config.c): one call to qs_config_load_startup() in Shock.c's main(), right after LoadPrefs(), and one check in sdl_events.c's pump_events() to drain the custom SDL events the menu's Save/Load Configuration buttons enqueue.
  • 22-android-screen-profile.patch - mainloop.c reports every loop-mode change to qs_screen_profile_notify_loop_mode(), which picks between the initial-screen and game-screen virtual-screen presets (see CONFIG.md) and tells the menu whether a game is running.
  • 23-android-intro-language.patch - cutsloop.c opens the intro cutscene file for the current language (svgaintr.res/svfrintr.res/ svgeintr.res), falling back to English when it isn't installed.
  • 24-android-cyberspace-query.patch - adds qs_in_cyberspace() to mouselook.c, which xr_input.c uses to switch between the on-foot and cyberspace controller actions.
  • 25-android-cyberspace-analog-scale.patch - movekeys.c scales the cyberspace pitch and bank strength by qs_cyber_pitch_scale/ qs_cyber_bank_scale, which xr_input.c sets from the thumbstick deflection each frame (1.0 for keys and buttons).
  • 26-android-cheat-refill.patch - adds qs_cheat_refill() to hkeyfunc.c, which tops up health, energy, ammo and items (grenades and drugs the player already holds) for the Cheats tab's unlimited switches.
  • 27-android-startup-splash.patch - setup.c's splash_draw() calls qs_splash_show() first, which fades the QuestShock title in and out on the game screen before the engine's own splash screens.

android/gl4es-patches/:

  • 01-android-cmake-subdirectory.patch - since gl4es is now add_subdirectory()'d straight into the engine's own CMake configure (via engine-patches/02 above) rather than built standalone, this skips gl4es's desktop-style output-directory/link_directories() setup and its libGL.so.1 SONAME versioning on Android, since AGP's native packaging needs a plain libGL.so and CMake's own default naming already produces that correctly.

5.5. Debugging notes

5.5.1. The menu quad rendering the game instead of itself

While building the OpenXR menu (android/app/src/main/cpp/xr_overlay.c - xr_menu.c at the time - and MenuOverlay.java), the menu's composition-layer quad consistently showed the game's own live rendering instead of the menu's content, even though every diagnostic (FBO bindings, swapchain/layer submission, texture upload, viewport/scissor state) checked out correct in isolation.

Root cause: the menu's blit was a normal glDrawArrays call routed through gl4es (the GLES/EGL translation layer the engine's desktop-style OpenGL calls go through on Quest). gl4es's fixed-pipeline emulation (fpe.c) decides whether to substitute its own generated shader for whatever program is bound by checking fpe_IsEmpty() - an all-zero-bytes check over its internal fixed-function state struct. But fpe_ReleventState() unconditionally sets one field of that struct, alphafunc, to a nonzero sentinel (FPE_ALWAYS) whenever alpha testing is disabled - which is true for essentially every draw call in this codebase. That means fpe_IsEmpty() could basically never be true, so gl4es was always substituting its own shader (reproducing the game's last-used texture/fixed-function state) for the menu's draw call, regardless of which program, textures, or GL state the menu code set up around it.

Fix: stop routing the menu's blit through gl4es's draw pipeline entirely. It's now a real (non-gl4es) GLES3 glBlitFramebuffer() - a pure hardware pixel copy with no shader/program/vertex-array stage at all - copying directly from the menu's own offscreen texture into the swapchain image. With no draw call and no shader involved, gl4es's fpe.c has nothing to intercept.

Two earlier, more plausible-looking theories were investigated and ruled out first: forcing the real (non-gl4es-shadowed) GL program to bind (real_glUseProgram), and disabling GL_TEXTURE_2D on every texture unit around the draw (an earlier, incomplete read of the fpe_IsEmpty() condition). Both were red herrings - neither touches the alphafunc field that was actually keeping the substitution permanently active.

Finding this took about a week of calendar time (2026-07-26 to 2026-08-01) spread across several sessions, mostly because each hypothesis required a full edit-rebuild-deploy-retest cycle on real Quest hardware to falsify (there's no way to reproduce gl4es's Android-only codepath on desktop). Most of that time went into working through gl4es itself - which is not this project's code - since the bug's actual trigger (a single always-nonzero struct field) sits well below the project's own layer boundary and doesn't show up in any of the local GL state that the menu's own code controls.

5.5.2. Diagonal-banded corruption in the 3D game view

During real 3D gameplay (not menus/splash), the game quad rendered as diagonally-banded color noise. Every OpenXR overlay quad (menu, keyboard, laser-beam pointers) and the desktop build's software renderer were unaffected, narrowing the bug to the one present path gameplay uses that those don't: android_draw_surface_as_quad() in engine/src/MacSrc/ OpenGL.cc.

Root cause: its glTexImage2D upload of the tightly-packed uiRgba conversion buffer relies on GL's default unpack state (GL_UNPACK_ROW_LENGTH/GL_UNPACK_ALIGNMENT at 0/4) instead of setting it explicitly. gl4es - the GLES/EGL translation layer this engine's desktop-style GL calls go through on Quest - implements both unpack parameters in software, manually repacking the source buffer row-by-row using whatever stride was last set via glPixelStorei, and that state persists across draw calls in the same GL context rather than resetting per call. During gameplay, this upload inherits non-default unpack state left behind elsewhere in the frame and misreads every row - a diagonal shear/banding pattern. Menus never trigger it because nothing in the menu-only code paths sets a custom unpack stride first.

5.5.3. In-game SFX and MIDI music didn't play

Intro cutscene audio played correctly, but in-game SFX and MIDI/background music never played, on-device.

Root cause: snd_start_digital() in engine/src/MacSrc/SDLSound.c opened two separate SDL audio devices - a raw SDL_OpenAudioDevice() call (used for cutscene/movie audio, always succeeded) and a second device via Mix_OpenAudio() (used for SFX and, via Mix_HookMusic, MIDI music - always failed, SDL_GetError() reporting "Audio device already open"). SDL's Android audio backends (both AAudio and OpenSL ES) only ever allow one open non-capture device - forcing OpenSL ES instead of AAudio (05-android-audio-driver.patch) doesn't change this, since the restriction isn't backend-specific.

Fix (16-android-audio-single-device.patch): removed the raw SDL_OpenAudioDevice() call entirely. Cutscene/audiolog PCM (previously delivered via that device's own callback) is now mixed into the single Mix_OpenAudio() device's output via Mix_SetPostMix(), additively (SDL_MixAudioFormat) rather than overwritten, so SFX/MIDI music already mixed into the buffer isn't silenced whenever a cutscene or audiolog plays. The stream-pointer swaps in cutsloop.c/audiolog.c that used to be guarded by pausing the now-removed device are guarded by SDL_LockAudio()/SDL_UnlockAudio() instead, since the post-mix hook runs continuously on SDL_mixer's own audio thread.

5.5.4. MIDI music silent even after audio otherwise worked

After fixing 5.5.3, SFX and cutscene audio played correctly, but MIDI background music was still silent, with logcat showing Creating native MIDI device instead of Creating FluidSynth MIDI device.

Root cause: the engine's midi-backend preference (0 = ADLMIDI, 1 = Native, 2 = FluidSynth) is persisted in a prefs.txt file in the app's private storage, which Android retains across every APK reinstall/upgrade (only a full uninstall clears it). A stale value of 1 (Native) from earlier testing was carried forward through every rebuild this whole session. SetDefaultPrefs() (Prefs.c) does default to 2 (FluidSynth) whenever USE_FLUIDSYNTH is defined, which it always has been for this build - the default was never wrong, only the persisted override. NativeMidiInit() has no real implementation on Android (see 17-android-native-midi-unsupported.patch), so it silently reported success without opening anything.

Fix: a full uninstall (not just installing a new build over the old one) clears the stale preference, after which MIDI plays correctly. 17-android-native-midi-unsupported.patch additionally makes NativeMidiInit() report failure on unsupported platforms, so a stale "Native" preference falls back to InitDecXMI()'s existing dummy-driver path (still silent, but with a clear WARN in logcat) instead of silently reporting success while never opening anything - a much faster diagnosis if this ever recurs.

5.5.5. Black, then flat-colored/untextured screen after switching to OpenGL rendering in-game

The engine has a Ctrl+G-bound in-game toggle (toggle_opengl(), Prefs.c) that cycles between the plain software renderer and OpenGL rendering. On Quest, switching to OpenGL rendering (either mode: unfiltered or bilinear) produced a black screen for the 3D view, with OpenGL error: 1282 (GL_INVALID_OPERATION) in logcat right at the switch.

Root cause: opengl_swap_and_restore() draws the offscreen-rendered 3D view onto a screen-filling quad using glBegin(GL_TRIANGLE_STRIP) / glVertexAttrib2f()/glVertexAttrib1f() / glVertex3f() immediate mode to supply per-vertex texcoords and light. This is exactly the same bug already found and fixed for the UI-overlay quad in 11-android-openxr-present.patch's android_draw_surface_as_quad(): under gl4es's GLES2 immediate-mode emulation, glVertexAttrib*() sets a constant value for the whole draw call, not a fresh value per vertex the way desktop GL's immediate mode does - only attribute 0 (position, driven by glVertex3f) gets real per-vertex capture. The 3D-view quad draw predates that fix and was never updated to match, so it kept using the broken pattern.

This patch's first revision only fixed that one quad, which did clear the black screen - but the next on-device test showed a "blocky" world instead: every wall/floor polygon rendered as a single solid color instead of its texture. The same broken immediate-mode pattern turned out to be used by every other textured/lit polygon draw in the file too: opengl_light_tmap() (every textured wall/floor polygon in the 3D view, via the shared draw_vertex() helper), opengl_bitmap() (sprites), and opengl_draw_poly() (flat/gouraud-shaded polygons, also via draw_vertex()) - each polygon's texcoord/light attribute ended up constant across all of its vertices (whatever was last set), instead of varying per vertex, hence the flat, solid-colored blocks.

Fix: 18-android-opengl-immediate-mode-attribs.patch adds android_imm_begin()/android_imm_vertex()/android_imm_end(), a small shared immediate-mode-emulation shim (buffers vertices, then issues one real glDrawArrays() call), and switches every glBegin/glVertexAttrib/ glVertex3f call site above to it, Android-only - the desktop path is unaffected and keeps the original immediate-mode code, which works correctly under real desktop OpenGL.

The same patch also fixes the starfield (opengl_begin_stars()/opengl_draw_star()/opengl_end_stars()), found while auditing the file for every other instance of this pattern rather than through an on-device symptom: each star is a separate GL_POINTS vertex with its own brightness set via the same glVertexAttrib1f/glVertex3f immediate-mode pair, so every star drawn between one begin/end call would have rendered at whatever brightness was set for the last one. Gets its own android_stars_begin()/ android_stars_vertex()/android_stars_end() buffer, shaped for GL_POINTS and up to NUM_STARS (GameSrc/rendtool.c) vertices rather than android_imm_*()'s small triangle-strip buffer.

5.5.6. Display breaks and stops responding to input after changing resolution in-game

Picking a different resolution from the in-game options menu (e.g. 1024x768) produced a single OpenGL error: 1282 in logcat and left the display frozen on the last frame - VrApi's FPS counter kept running at 90fps (the OpenXR compositor was still submitting frames), but nothing in the menu responded to further input afterward.

Root cause: ChangeScreenSize() (ShockBitmap.c) calls SDL_RenderSetLogicalSize() to the new resolution and reallocates drawSurface/offscreenDrawSurface accordingly, but on desktop the actual GL-side work - opengl_resize() in OpenGL.cc, which (re)creates backupBuffer (the offscreen render target the 3D view draws into) at the new size - only runs later, from the SDL_WINDOWEVENT_RESIZED/SIZE_CHANGED event that SDL_SetWindowSize() generates. 09-android-no-window-resize.patch skips that SDL_SetWindowSize() call on Android for an unrelated reason (see its own entry above) - which also means no such event, and therefore no opengl_resize() call, ever happens on Android when the in-game resolution changes. backupBuffer stayed stuck at whatever size it was created with at launch while the rest of the game moved on to the new resolution, and that mismatch is what broke rendering and left the menu unable to correctly hit-test further clicks.

Fix: ChangeScreenSize() now calls opengl_resize() directly on Android, right after updating gScreenWide/gScreenHigh (matching the desktop event's actual timing, since SDL_PollEvent() always delivers it after ChangeScreenSize() has already returned) - added to 09-android-no-window-resize.patch since this is a direct, foreseeable consequence of that patch's own change, not a new mechanism.

5.5.7. Laser-pointer cursor disappears after changing resolution in-game

After 5.5.6's fix, changing resolution stopped breaking the display, but the laser-pointer-driven mouse cursor (see section 5's architecture overview) would disappear and stop being able to click anything afterward.

Root cause: xr_get_game_resolution() (xr_session.c), which xr_input.c uses to convert a game-quad ray hit's normalized u,v into the pixel coordinates it feeds the engine via xr_mouse_move(), returned the game quad's OpenXR swapchain's fixed pixel size (g_game_swapchain.width/height) - set once at launch and never resized. android_draw_surface_as_quad() (OpenGL.cc, 11-android-openxr-present.patch) always stretch-blits drawSurface to fill that swapchain image via a single full-quad draw, regardless of drawSurface's own pixel size, so the swapchain's fixed size has no actual relationship to the coordinate space MouseX/MouseY live in - that's drawSurface's own current size (gScreenWide/gScreenHigh), which ChangeScreenSize() can change at runtime independently of the swapchain. Using the stale swapchain size there mapped every ray hit into the wrong coordinate space, moving the mouse position out of the engine's actual screen bounds.

Fix: xr_get_game_resolution() now returns gScreenWide/ gScreenHigh instead. xr_get_game_quad_extent() (the quad's shape and distance in the VR world) is intentionally left as-is, still tied to the swapchain's fixed size - that's a real, unchanging property of the swapchain image itself, unrelated to this bug.

5.5.8. Persistent OpenGL error: 1282 logcat spam during 3D gameplay

Every logcat capture during actual 3D gameplay showed a continuous stream of OpenGL error: 1282 (roughly twice per frame) from the one pre-existing glGetError() check in opengl_swap_and_restore(), with no visible effect on rendering (textures, resolution changes, and the cursor all worked correctly per sections 5.5.5-5.5.7's fixes). 1282 is GL_INVALID_OPERATION (0x0502) - not GL_INVALID_ENUM (0x0500/1280), which earlier debugging had mistakenly assumed given the visual symptoms resembled the immediate-mode bug in 5.5.5.

Two real, unrelated bugs turned out to be involved, found by adding a temporary chain of glGetError() checkpoints across the whole per-frame render path (mainloop() → render_run() → fr_rend() → fr_prepare_view()/fr_start_view()/fr_send_view()) and bisecting which ones read clean:

xrPollEvent() leaves a stray error. xr_poll_events() (xr_session.c) left gl4es reporting GL_INVALID_OPERATION on every call while the session is running, regardless of what this app's own code does. Fixed by draining it (while (glGetError() != GL_NO_ERROR)) right after the polling loop.

A hack-camera render re-entered the OpenXR frame setup. render_hack_cameras() (render.c) calls fr_rend() - and so fr_start_view()/fr_send_view() - a second time per real frame for each active security-camera monitor, rendering into a private software (FR_DOUBLEB_MASK) bitmap rather than the actual GL-backed screen. use_opengl() already excludes hack-camera/pickup-mode views from the GL texture-mapping functions (FR_PICKUPM_MASK|FR_HACKCAM_MASK), but should_opengl_swap() - the separate check gating opengl_start_frame()/opengl_end_frame() in those two functions - didn't, so those ran again mid-frame for a render that was never GL-backed. 19-android-fix-hackcam-opengl-frame.patch adds the same exclusion to both call sites. This is a genuine bug fixed on its own merits, but bisecting further showed it wasn't actually the source of the 1282 spam - removing it just relocated the checkpoint that caught the error, without changing whether it fired.

Actual root cause: binding "framebuffer 0" on Android has no valid target. opengl_end_frame()'s BindFrameBuffer(nullptr) binds framebuffer/renderbuffer id 0 to mean "the real window surface" - true on desktop, where that's SDL's actual GL window. On Android, this app's real presentation target is never that window's own EGL surface at all - it's the OpenXR swapchain's per-image FBOs (xr_swapchain_create()/xr_swapchain_acquire(), xr_swapchain.c). Whatever framebuffer/renderbuffer 0 actually resolves to for gl4es in this configuration, binding it reliably set a persistent GL_INVALID_OPERATION - confirmed by bisecting through the entire render path with per-call checkpoints, all clean right up until the one placed immediately after this exact call. The bind is also functionally dead regardless of the error: by the time anything next draws, Shock.c's SDLDraw() has already run xr_frame_begin() → xr_swapchain_acquire(), which rebinds to that frame's real swapchain FBO before opengl_swap_and_restore() (the only thing that draws before the next opengl_start_frame()'s own BindFrameBuffer(&backupBuffer)) runs - so nothing ever actually renders while framebuffer 0 is bound. 20-android-no-default-framebuffer.patch skips the call on Android entirely. Confirmed via logcat: this fully eliminated the error across an extended play session with 3D rendering active throughout, with no change in what actually renders.

An earlier theory (that the OpenXR runtime's own GL work inside xrAcquireSwapchainImage/xrWaitSwapchainImage/xrEndFrame was leaving stray driver-level errors on the shared EGL context) prompted a dlsym'd, gl4es-bypassing glGetError() drain at those call sites. Once the above was fixed, testing showed that drain was never actually necessary, so it was removed rather than kept as unproven defensive code.

6. License

The original tooling in this repository (the Docker build image, build scripts, Makefile, asset extraction script, and the Quest app in android/ - aside from org/libsdl/app/, see below) is licensed under the MIT License.

android/app/src/main/java/org/libsdl/app/ is copied from SDL2's own android-project template and is zlib-licensed, same as SDL2 itself.

The Android build also bundles GL4ES (lib/arm64-v8a/libGL.so in the APK, compiled at APK build time from the vendored snapshot in android/gl4es-src/, patched via android/gl4es-patches/ the same way engine/ is - see below), which translates the engine's desktop-style OpenGL calls into GLES/EGL and is MIT-licensed.

It also bundles the Khronos Group's own OpenXR-SDK loader (lib/arm64-v8a/libopenxr_loader.so, prebuilt unmodified into the build image), which is Apache 2.0-licensed.

The vendored engine snapshot in engine/ is Shockolate, which is licensed under the GNU GPLv3 (see engine/LICENSE) - it is included unchanged and is not relicensed by this project's MIT license. Any build or distribution of the compiled engine must comply with the GPLv3.

System Shock is a trademark of its respective owners. Questshock is an unofficial fan project and is not affiliated with, endorsed by or supported by them.

The game assets extracted into res/assets/ss_ee/ are proprietary, copyrighted game data owned by their respective rightsholders - they are never committed to this repository (see .gitignore) and must be supplied by each user from their own legitimate purchase.

NOTICE.txt at the repository root summarizes the above and is bundled into the release tarball by make package (see "4.1. Packaging a distributable build" above) - keep the two in sync.

S
Description
An open source project to play the classic 1994 System Shock on a VR headset, built on top of Shockolate, a cross-platform port of the original game.
Readme MIT 111 MiB
Languages
C 75.1%
Go 10.9%
C++ 9.7%
Java 2.7%
Shell 0.6%
Other 0.8%