- New "Language" row on the Main Menu tab (English/French/German), backed by a new "general" group in questshock.config.json - Fixes a device-reproduced crash: switching language used to call language_dealfunc(), whose render_run()/opanel_redraw() repaint assumes the game's own in-game options panel is on screen - crashes (SIGSEGV) when triggered from our independent VR overlay instead. Now calls language_change() directly, which is all that's actually needed to reload resources and refresh the inventory/MFD/side-icon UI - Fixes a second crash: this data set ships frnstrng.res/gerstrng.res but not mfdfrn.res/mfdger.res, and language_change() closes the current resource files before discovering the new ones don't exist - leaving no valid resource file open and crashing on the next lookup. Now checks both files exist before ever switching, refusing the change (with an in-headset popup) instead of corrupting resource state - extract_assets.sh can now also pull mfdfrn.res/mfdger.res out of a System Shock: Classic installer (which includes them, unlike Enhanced Edition) if one is present in res/assets/ - entirely optional - Documented all of the above in README.md/CONFIG.md
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.
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.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 - compilingengine/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.shextracts it intoss_ee/.res/run.sh- the launcher script, copied intodist/on build.Makefile- targets:all(default) - alias fordist.build-image- builds the Docker build-image (./build-image.sh). Only needed once, or afterbuild-image/changes.engine- compilesengine/(the vendored Shockolate snapshot) via the build-image, offline. Always re-run by the targets below, sodist/package/apkstay in sync with the currentengine/source.assets- preflight check only: fails with a pointer tores/assets/extract_assets.shif the purchased game assets (see "3. Game assets" below) haven't been extracted yet.dist- assemblesdist/, a self-contained runnable copy of the game, out of the compiled engine and the extracted assets (depends onengine+assets). See "4. Desktop build" below.package- builds a versioned, redistributabledist/shockolate-<version>-linux-<arch>.tar.gzthat omits the proprietary game assets (shipsres/GET_ASSETS.txtin their place). See "4.1. Packaging a distributable build" below.apk- buildsdist/questshock-<version>-android-arm64.apkfor Android-based OpenXR headsets. 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- stagesengine//android/gl4es-src/(scratch, patched copies) and the Android prebuilts with host-resolvable paths, so Android Studio can compile and deployandroid/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 (JavaSDLActivityglue, Gradle project).android/engine-patches/holds the patches needed to buildengine/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), withandroid/gl4es-patches/applied to a scratch copy at build time (same asengine/) 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.
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.shbuild 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 buildingdist-winneeds nothing extra beyond Docker itself. innoextractandunzip- optional, only needed byres/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 formake package/package-win(the Windows release archive);make dist/dist-winandpackage-linuxdon'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), so unlike the Quest
build's android/engine-patches/, no source patching is needed here -
this cross-compiles those same branches offline and reproducibly, from
Linux, via Docker.
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.
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 apkneeds its own separate toolchain - see "5.2. Building natively in Android Studio" below.
make apk builds dist/questshock-<version>-android-arm64.apk - 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 launcher (MenuOverlay.java - just a "Keyboard" button so far)
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, and by whatever similar panel comes next) with its own swapchain, visibility, and position, so they can be shown/hidden/moved independently - a design specifically meant to let the keyboard stay open while also picking something from the (still-to-be-built-out) menu. The controller's menu button only ever opens/closes the launcher; clicking its "Keyboard" button only opens the keyboard (never touching the launcher'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
- Install the APK with SideQuest (or
adb install). - 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/andres/sound/are deliberately left missing, since that's the proprietary game data. - With the headset connected to a PC, use SideQuest's file browser (or
any MTP file manager) to copy your own
res/data/andres/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/. - 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.
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:
- 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 connectworks too, once paired once over USB). - Confirm it's visible with
adb devices- it should also show up in Android Studio's device dropdown. - 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 stagedengine/sources) - Android Studio builds, installs, and launches the app on the headset directly. No manualadb installor SideQuest step needed for this loop. /sdcard/questshock/(game assets, extracted shaders/soundfont) persists across reinstalls from Android Studio, so this loop doesn't require re-copyingres/data//res/sound/on every iteration - only a full uninstall or wiping that directory clears it.- 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 taggedQuestShock, so filtering Logcat by that tag shows everything questshock-specific without gl4es/OpenXR loader/system noise (drop the filter to see those too). - Only re-run
make android-studiomanually (see above) after changingengine/,android/engine-patches/,android/gl4es-src/,android/gl4es-patches/, or the prebuilt-library versions inbuild-image/Dockerfile- the Gradle build'sstageEnginetask does this automatically otherwise, so plain Java/C++ edits underandroid/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 buildsCMakeLists.txt'smaintarget as aSHAREDlibrary on Android instead of the desktopsystemshockexecutable, folding in the Android-native glue sources (ANDROID_EXTRA_SOURCES).02-android-opengl-es.patch- replaces the desktopfind_package(OpenGL)withadd_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 hasGL_UNPACK_ROW_LENGTH/GL_CLAMP_TO_BORDERas core, avoiding workarounds needed on GLES 2.0).04-android-opengl-es-render.patch- swaps a few gl4es rough edges (its shader-rewrite-basedglPointSize/alpha-test/point-sprite emulation) for real GLES-native equivalents: a custompointSizeuniform, 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 viaSDL_SetHintWithPriority(..., SDL_HINT_OVERRIDE)(so nothing else can take precedence and silently leave AAudio selected), and logs the actualSDL_GetCurrentAudioDriver()result right afterSDL_Init()as a standing on-device diagnostic. Does not by itself fix SFX/MIDI playback - see16-android-audio-single-device.patchand README 5.5.3 for the actual cause and fix.06-android-resize-event.patch- makes the engine react toSDL_WINDOWEVENT_RESIZED, not justSIZE_CHANGED, since Android's SDL backend only ever sends the former for surface-driven resizes.07-android-logcat-link.patch- links Android'sloglibrary.08-android-logcat-output.patch- routes the engine's ownlog.c(INFO/DEBUG/WARN/ERROR) through__android_log_vprintso it reaches logcat, instead of stdio (which Android never captures).09-android-no-window-resize.patch- skips the desktop-onlySDL_SetWindowFullscreen/SetWindowSize/SetWindowPositioncalls 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 callsopengl_resize()directly inChangeScreenSize()(see README 5.5.6) - skippingSDL_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: redirectsSDLDraw()/opengl_swap_and_restore()to submit into the XR swapchain (viaxr_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 whengShockPrefs.doUseOpenGLis true - it defaultsfalseinPrefs.c, so this path is dormant on Quest by default) setsGL_UNPACK_ROW_LENGTHfrom 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()usedglFlush()after drawing into the swapchain's FBO, which queues the GPU work without waiting for it to finish beforexr_swapchain_release()hands the image to the OpenXR runtime for cross-process consumption. Changed toglFinish(), which blocks until the write has landed. The same gap existed inxr_overlay.c'sxr_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()'sglTexImage2Dupload of the tightly-packeduiRgbabuffer relied on GL's default unpack state (GL_UNPACK_ROW_LENGTH/GL_UNPACK_ALIGNMENTat 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()'sMix_OpenAudio()failure only logged a fixed string, not the actual reason. AddsSDL_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 rawSDL_OpenAudioDevice()for cutscene/audiolog audio and a second one viaMix_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 theMix_OpenAudio()device's output instead, viaMix_SetPostMix()(mixing in rather than overwriting, so it doesn't silence any SFX/MIDI music playing at the same time).cutsloop.c's andaudiolog.c'sSDL_PauseAudioDevice()calls (no longer meaningful without a dedicated device) are removed; the cutscene-audio-stream pointer swaps they guarded are now protected bySDL_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()inMusicDevice.chas real implementations only under#ifdef WIN32and#elif defined(USE_ALSA); on every other platform (Android included) it fell through toreturn 0(success) without ever opening a device, so amidi-backendpreference of "Native" (see README 5.5.4) played silently forever instead of falling back. Adds an#elsebranch that returns failure and logs aWARN, soInitDecXMI()'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). EveryglBegin(GL_TRIANGLE_STRIP)-based draw inOpenGL.cc-opengl_swap_and_restore()'s screen-composite quad,opengl_light_tmap()(every textured wall/floor polygon, viadraw_vertex()),opengl_bitmap()(sprites), andopengl_draw_poly()(flat/gouraud polygons, also viadraw_vertex()) - usedglVertexAttrib2f/glVertexAttrib1f/glVertex3fimmediate 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. Addsandroid_imm_begin()/android_imm_vertex()/android_imm_end(), a small shared immediate-mode-emulation shim that buffers vertices and flushes them as one realglDrawArrays()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_POINTSrather thanGL_TRIANGLE_STRIP, up toNUM_STARS=1000 per frame) had the same bug for its per-star brightness and gets its own similarly-shapedandroid_stars_begin()/android_stars_vertex()/android_stars_end()buffer. Also skips the unconditionalSDL_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-frameGL_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) calledopengl_start_frame()/opengl_end_frame()again for hack-camera security-monitor renders (which target a private software bitmap, not the GL-backed screen), sinceshould_opengl_swap()doesn't exclude them the wayuse_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-frameGL_INVALID_OPERATION(1282) in README 5.5.8:opengl_end_frame()'sBindFrameBuffer(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 inxr_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 toqs_config_load_startup()inShock.c'smain(), right afterLoadPrefs(), and one check insdl_events.c'spump_events()to drain the custom SDL events the menu's Save/Load Configuration buttons enqueue.
android/gl4es-patches/:
01-android-cmake-subdirectory.patch- since gl4es is nowadd_subdirectory()'d straight into the engine's own CMake configure (viaengine-patches/02above) rather than built standalone, this skips gl4es's desktop-style output-directory/link_directories()setup and itslibGL.so.1SONAME versioning on Android, since AGP's native packaging needs a plainlibGL.soand 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.
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.
