Files
questshock/README.md
T
2026-09-20 17:23:55 +02:00

999 lines
55 KiB
Markdown

# Questshock
![questshock](res/logo_small.png)
An open source project to play the classic 1994 System Shock on a VR
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
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.
## 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 - 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` - the launcher script, copied into `dist/` on build.
- `res/questshock.config.default.json` - the bundled default configuration
(see [CONFIG.md](CONFIG.md)); `res/controller_profiles/` - the per-controller
illustrations and labels shown in the Controller Options tab (see
[CONFIG.md](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. 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](https://www.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](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.
```sh
# 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.
### 4.3. GitHub releases
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 built with `assembleDebug`, so it is signed with the debug key of
the machine that built it. Android only updates an installed app from an APK
with the same signing key.
## 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` - 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](https://sidequestvr.com/) (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](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](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:
```sh
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](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](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](LICENSE).
`android/app/src/main/java/org/libsdl/app/` is copied from
[SDL2](https://www.libsdl.org/)'s own android-project template and is
zlib-licensed, same as SDL2 itself.
The Android build also bundles [GL4ES](https://github.com/ptitSeb/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](
https://github.com/KhronosGroup/OpenXR-SDK) (`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](https://github.com/Interrupt/systemshock), 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.