a8d7ff76df
build / build (push) Successful in 2m41s
g3_bitmap_common() (Bitmap.c) used the same bm_w/bm_h for both the on-screen quad size and its texture-coordinate bounds. The former should use patch 04's native-size override for a modded/upscaled bitmap; the latter must always use the bitmap's own real dimensions, since that's still what's actually being sampled. OpenGL never hit this (opengl_bitmap() ignores these bounds and always maps the whole real texture), but the software renderer's h_map() takes them as literal pixel coordinates to interpolate, so the override capped how much of a larger bitmap it ever read - cropping anything past the original sprite's bounds, such as an upscaled critter's limbs. Split into a separate tex_w/tex_h pair, always the bitmap's real size, used for every texture-coordinate computation. README.md: document this, and the previously-undocumented engine-debug-tools RSD8 decode tracing patch (02).
1283 lines
72 KiB
Markdown
1283 lines
72 KiB
Markdown
# Questshock
|
|
|
|

|
|
|
|
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.
|
|
|
|
> **Unofficial fan project.** Questshock is not affiliated with, endorsed by or
|
|
> supported by the rights holders of System Shock. It contains no game data: you
|
|
> need your own copy of System Shock: Enhanced Edition.
|
|
|
|
## Screenshots
|
|
|
|
<table>
|
|
<tr>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_001.jpg" alt="Exploring the station on the virtual screen"><br><sub>Exploring the station on the virtual screen</sub></td>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_003.jpg" alt="Reading an email - the laser pointer clicks like a mouse"><br><sub>Reading an email - the laser pointer clicks like a mouse</sub></td>
|
|
</tr>
|
|
<tr>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_005.jpg" alt="Cyberspace"><br><sub>Cyberspace</sub></td>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_006.jpg" alt="The game screen, the menu and the keyboard float independently"><br><sub>The game screen, the menu and the keyboard float independently</sub></td>
|
|
</tr>
|
|
<tr>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_007.jpg" alt="The VR menu"><br><sub>The VR menu</sub></td>
|
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_008.jpg" alt="Assigning controller inputs to game actions"><br><sub>Assigning controller inputs to game actions</sub></td>
|
|
</tr>
|
|
</table>
|
|
|
|
[A short video](res/screenshots/questshock_v0.5.3_010.mp4) shows the game in use.
|
|
|
|
## Quick start
|
|
|
|
You need a Meta Quest (tested on a Quest 2) and your own copy of the game data.
|
|
|
|
1. Get **System Shock: Enhanced Edition** (for example from
|
|
[gog.com](https://www.gog.com/)); see "3. Game assets" below.
|
|
2. Download `questshock-<version>-android-arm64.apk` from the repository's
|
|
Releases page and sideload it onto the headset with
|
|
[SideQuest](https://sidequestvr.com/) or `adb install`.
|
|
3. Launch it once. It creates `/sdcard/QuestShock/`. Copy your game's `res/data/`
|
|
and `res/sound/` folders into `/sdcard/QuestShock/res/`.
|
|
4. Launch it again. The left controller's menu button opens the menu; the
|
|
default controls are in "5.1.1. Menu, controls and cheats" below.
|
|
|
|
Section "5.1. Installing and playing" has the details.
|
|
|
|
## 1. Design principles
|
|
|
|
- **Standalone-headset-only.** The game must run entirely on the headset
|
|
itself - no PC required, whether tethered or streamed (not PCVR). Any
|
|
feature or dependency that assumes a host PC is out of scope.
|
|
- **OpenXR-first.** Favor OpenXR-standard APIs over vendor-specific ones
|
|
(e.g. Meta's Oculus Mobile SDK) wherever there's a choice, so the port
|
|
isn't locked to Meta Quest and can work across other standalone,
|
|
Android-based OpenXR headsets (e.g. Pico) too.
|
|
|
|
## 2. Layout
|
|
|
|
- `engine/` - a vendored snapshot of the Shockolate engine source. Built
|
|
via Docker; see "4. Desktop build" below. `engine-patches/`
|
|
holds general (not Android-specific) fixes applied to a scratch copy at
|
|
build time - same convention as `android/engine-patches/` below, but
|
|
for the Linux/Windows desktop builds; `engine/` itself is never
|
|
modified. See "4.4. Engine patch reference" below for what each one does.
|
|
- `build-image/` - the Dockerfile (and supporting scripts) for the engine
|
|
build environment. Every third-party dependency the engine needs to
|
|
compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI
|
|
soundfont) is fetched and built once into this image - compiling
|
|
`engine/` itself needs no network access.
|
|
- `build-image.sh` / `run-image.sh` / `upload-image.sh` - build the image,
|
|
run it to compile the engine, and push it to a registry, respectively.
|
|
- `res/assets/` - where you place your own purchased copy of the game (see
|
|
"3. Game assets" below); `res/assets/extract_assets.sh` extracts it into
|
|
`ss_ee/`.
|
|
- `res/run.sh` / `res/run.bat` - the launcher scripts, copied into
|
|
`dist`/`dist-win` on build. `res/run-mods.sh` / `res/run-mods.bat` are
|
|
companion launchers that additionally load every mod built under the
|
|
`modding/` subproject's own `dist/mods/` (see
|
|
[modding/README.md](modding/README.md) section 4.3) - copied into
|
|
`dist`/`dist-win` the same way, but not into the redistributable
|
|
`package`/`package-win` archives, since that build output is local and
|
|
git-ignored.
|
|
- `res/questshock.config.default.json` - the bundled default configuration
|
|
(see [CONFIG.md](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 (a debug build, `...-arm64-debug.apk`,
|
|
when there is no signing key - see "4.3. Releases and signing" below).
|
|
Unlike every other target, this needs network access at build time
|
|
(Gradle/AGP's own dependency resolution). See "5. Android / Quest
|
|
build" below.
|
|
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
|
|
patched copies) and the Android prebuilts with host-resolvable paths,
|
|
so Android Studio can compile and deploy `android/` natively instead
|
|
of inside the build-image. See "5.2. Building natively in Android
|
|
Studio" below.
|
|
- `clean` - removes all build output (`dist/`, `build/`, compiled
|
|
engine artifacts, and the staged Android project files).
|
|
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
|
|
`android/engine-patches/` holds the patches needed to build `engine/`
|
|
as an Android shared library instead of a desktop executable - applied
|
|
to a scratch copy at build time; `engine/` itself is never modified.
|
|
`android/gl4es-src/` similarly vendors GL4ES (see "6. License" below), with
|
|
`android/gl4es-patches/` applied to a scratch copy at build time (same
|
|
as `engine/`) and compiled from source alongside it, not prebuilt. See
|
|
"5. Android / Quest build" below for what each patch does.
|
|
|
|
## 3. Game assets
|
|
|
|
System Shock's game data is not included in this repository and cannot
|
|
be redistributed - you need to own a copy. Buy **System Shock: Enhanced
|
|
Edition** on [gog.com](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`) - this cross-compiles those
|
|
same branches offline and reproducibly, from Linux, via Docker. It applies
|
|
the same `engine-patches/` series the Linux build does (section
|
|
4.4); unlike the Quest build's `android/engine-patches/`, no
|
|
Windows-specific source patching is needed on top of that.
|
|
|
|
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
|
|
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
|
|
sitting flat alongside it, rather than in a `lib/` subdirectory like
|
|
`dist/` - Windows' default DLL search order already checks the
|
|
executable's own directory first, so no `PATH`/library-path setup is
|
|
needed the way `dist/run.sh` needs `LD_LIBRARY_PATH`.
|
|
|
|
Confirmed working (and playable) on a real Windows machine.
|
|
|
|
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
|
|
package on every push, using the build-image as its container (so no
|
|
extra setup is needed in CI beyond the image itself), and publishes the
|
|
resulting tarball to dl.ladkau.de.
|
|
|
|
### 4.3. Releases and signing
|
|
|
|
The repository is developed on Gitea, and the GitHub repository is a mirror of
|
|
it. `.github/workflows/release.yml` is GitHub's own workflow (the Gitea build
|
|
is `.gitea/workflows/build.yml`):
|
|
|
|
- Pushing a tag `vX.Y.Z` builds the Linux and Windows packages and the Quest
|
|
APK inside the build-image and attaches them to a GitHub release with the
|
|
same name. A manual run ("Run workflow") builds the same files without
|
|
releasing them and keeps them as a downloadable workflow artifact.
|
|
- The build-image is pushed to GitHub's container registry (`ghcr.io`) when it
|
|
does not exist there yet. Its tag is a hash of `build-image/Dockerfile`,
|
|
`docker-entrypoint.sh` and `build-engine.sh`, so a change to any of them
|
|
builds a new image (`build-image/VERSION` is not used). The first run is slow
|
|
because it builds the image.
|
|
- The APK is signed with the release key described below.
|
|
|
|
**Signing.** Android only updates an installed app from an APK signed with the
|
|
same key, so every distributed APK is signed with one fixed key. `make apk`
|
|
(`build-image/build-apk.sh`) builds and signs the release APK when
|
|
`QUESTSHOCK_KEYSTORE` is set, together with `QUESTSHOCK_KEYSTORE_PASSWORD` and
|
|
optionally `QUESTSHOCK_KEY_ALIAS` (default `questshock`) and
|
|
`QUESTSHOCK_KEY_PASSWORD` (default: the keystore password). Without a keystore
|
|
it builds a debug APK, `questshock-<version>-android-arm64-debug.apk`, signed
|
|
with a per-machine debug key. That is what Android Studio's Run uses too; an
|
|
installed build has to be uninstalled before one signed with another key can be
|
|
installed.
|
|
|
|
Create the key once and keep the file and its password safe: without them,
|
|
users have to uninstall the app to get an update.
|
|
|
|
```sh
|
|
keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \
|
|
-alias questshock -keyalg RSA -keysize 4096 -validity 10000
|
|
base64 -w0 questshock-release.jks # the value of ANDROID_KEYSTORE_BASE64
|
|
```
|
|
|
|
Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`,
|
|
`ANDROID_KEYSTORE_PASSWORD`, and optionally `ANDROID_KEY_ALIAS` and
|
|
`ANDROID_KEY_PASSWORD`. Without `ANDROID_KEYSTORE_BASE64`, the Gitea build
|
|
produces a debug APK and the GitHub build fails on a tag. `*.jks` and
|
|
`*.keystore` are git-ignored.
|
|
|
|
### 4.4. Engine patch reference
|
|
|
|
`engine-patches/` (applied to a scratch copy of `engine/` by
|
|
`build-image/build-engine.sh` and `build-engine-win.sh`) is a plain,
|
|
numbered patch series, same convention as the Quest build's
|
|
`android/engine-patches/` (section 5.4) - `engine/` itself is never
|
|
modified. It only carries fixes that are general (not Android-specific);
|
|
anything Quest/OpenXR-only belongs in `android/engine-patches/` instead,
|
|
which the desktop build does not apply.
|
|
|
|
- **`01-desktop-texture-row-length.patch`** - `opengl_cache_texture()`/
|
|
`set_texture()` (`OpenGL.cc`) passed the original 8-bit bitmap's own row
|
|
stride (`bm->row`) to `GL_UNPACK_ROW_LENGTH`, describing the wrong
|
|
layout to OpenGL for the actually-uploaded buffer (a separately
|
|
allocated RGBA32 conversion, `converted->pixels`) whenever the two
|
|
strides differ - corrupting the texture upload. Uses
|
|
`converted->pitch / 4` instead.
|
|
- **`02-desktop-audio-error-detail.patch`** - logs `SDL_GetError()`
|
|
alongside "Couldn't open audio device" instead of just the bare message.
|
|
- **`03-desktop-fix-mod-resource-priority.patch`** - `LoadModFiles()`
|
|
(`Modding.c`) registers mod resource files once at startup, but two
|
|
places re-open the base game's own object/texture art files afterward -
|
|
and opening a resource file re-registers every ID it contains,
|
|
unconditionally, with no check for whether a mod already claimed it (see
|
|
`ResReadDirEntries()` in `resfile.c`). That silently reverted any mod
|
|
override for that art back to the base game's own graphics, twice over:
|
|
`obj_set_secondary_properties()` (`objsim.c`) re-opens `objart2.res`/
|
|
`objart3.res` transiently, just to read frame-count metadata; later in
|
|
the same startup sequence, `load_dynamic_memory()` (`cybmem.c`) re-opens
|
|
`texture.res`/`handart.res`/`objart2.res`/`objart3.res` again, this time
|
|
persistently - these are the handles actual gameplay renders from, so
|
|
this second point is what mattered for the mod override actually
|
|
showing up in-game. Fixed by re-running `LoadModFiles()` right after each
|
|
place's re-opens, so mods regain priority. Re-registering a mod file's
|
|
resource IDs means actually reopening it (there's no "just re-register"
|
|
primitive), so `LoadModFiles()` now closes each mod file's previous
|
|
handle first - one open handle per mod file at a time, rather than
|
|
leaking a fresh file slot on every repeat call (a new game or a loaded
|
|
save can both re-run this whole sequence).
|
|
- **`04-desktop-fix-critter-sprite-scale.patch`** - critters render as
|
|
camera-facing 2D billboards (`gameobj.c`'s `FAUBJ_CRIT` case), not
|
|
UV-mapped 3D polygons, and their on-screen size comes directly from the
|
|
bitmap's own pixel width/height (`Bitmap.c`'s `g3_bitmap_common()`, via
|
|
`_g3d_bitmap_u_anchor`/`_g3d_bitmap_v_anchor` and `bm_w`/`bm_h`) - so a
|
|
mod that replaces a critter's art with a higher-resolution bitmap for
|
|
more detail made the critter itself render bigger, proportionally to
|
|
the resolution increase. Fixed by caching every critter view's base-game
|
|
bitmap size per animation frame (`objsim.c`'s
|
|
`cache_critter_native_size()`/`get_critter_native_size()`, keyed by
|
|
resource ID and frame index - an animation's bounding box commonly
|
|
changes size between poses, so a single per-view size doesn't fit every
|
|
frame of it) while `objart2.res`/`objart3.res`'s base-game data is still
|
|
the active resolver in `obj_set_secondary_properties()` (before patch
|
|
03's `LoadModFiles()` call hands priority to a mod), then using that
|
|
cached size instead of the bitmap's own (possibly modded) dimensions
|
|
when computing the sprite's on-screen extent - threaded through
|
|
`_fr_draw_bitmap()` and `g3_light_anchor_bitmap()`'s new `native_w`/
|
|
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
|
|
unrelated bitmap draw never inherits a stale override. A mod's higher
|
|
resolution now only changes visible detail, not the object's size. Since
|
|
this same size also sets `g3_bitmap_common()`'s texture coordinate
|
|
range, not just its world-space extent, caching only the first frame's
|
|
size and reusing it for every frame of an animation also visibly
|
|
corrupted the UV mapping on any frame whose real size differed from the
|
|
first - caught once a mod's per-frame sizes started genuinely varying
|
|
(see `modding/README.md` section 5).
|
|
This depends on the mod's bitmap keeping a real anchor point in its own
|
|
`Header.Area`, in the original, un-upscaled unit space (see
|
|
`modding/src/mdlp/upscale.go`'s `imageToBitmap()`) - `_fr_draw_bitmap()`
|
|
only forwards `native_w`/`native_h` down the anchor-aware rendering path
|
|
when given a positive anchor, so a zeroed-out one silently skips this
|
|
patch's override entirely, and an anchor scaled to the mod's higher
|
|
resolution instead of left in the original unit space visibly offsets
|
|
the sprite from where it should sit, since it shares its coordinate
|
|
system with this patch's own size override rather than with the
|
|
bitmap's real pixel dimensions.
|
|
|
|
`g3_bitmap_common()`'s world-space size and its texture-coordinate range
|
|
are computed from the same `bm_w`/`bm_h` locals, but only the former
|
|
should ever use this patch's override - the pixels actually being
|
|
sampled are still the bitmap's own real (possibly upscaled) buffer,
|
|
regardless of what on-screen size it's drawn at. Overriding both left the
|
|
texture-coordinate range capped at the smaller, un-upscaled size while
|
|
the real bitmap underneath it was larger, so the renderer only ever
|
|
sampled that smaller top-left region of it - cropping off anything past
|
|
it, such as an upscaled critter's limbs extending beyond its original
|
|
sprite's bounding box. OpenGL never showed this: `opengl_bitmap()`
|
|
ignores `g3_bitmap_common()`'s computed `u`/`v` entirely and always maps
|
|
the *whole* bound texture over the quad via fixed `0.0`-`1.0` corners.
|
|
The software renderer's own scanline texture mapper (`h_map()`,
|
|
`gentm.c`) has no such shortcut - it takes `u`/`v` as literal pixel
|
|
coordinates into the bitmap and interpolates between exactly the values
|
|
it's given, so a capped range there directly caps how much of the real
|
|
bitmap it ever reads. Fixed by splitting `g3_bitmap_common()`'s single
|
|
`bm_w`/`bm_h` into the override-aware pair (world-space size only) and a
|
|
`tex_w`/`tex_h` pair that always holds the bitmap's own real dimensions,
|
|
used for every texture-coordinate computation (including the later
|
|
blend-buffer-size check, which sizes a buffer against the same real
|
|
pixel data these coordinates sample from).
|
|
- **`05-desktop-fix-texture-cache-collision.patch`** - `OpenGL.cc`'s
|
|
per-bitmap GL texture cache (`texturesByBitsPtr`) was keyed by
|
|
`bm->bits | (w << 32) | (h << 48)`, a plain bitwise OR rather than a
|
|
genuinely disjoint encoding - `bm->bits` is a full pointer, and a 64-bit
|
|
Linux heap/mmap address routinely has non-zero bits in the 32-47 range
|
|
the width field occupies, so a different `(bits, w, h)` triple can
|
|
collide with an already-cached entry's key. `bm->bits` itself is a
|
|
recycled slot in the resource system's own bounded LRU cache, not a
|
|
stable per-resource address, making a collision a real risk rather than
|
|
a theoretical one. On a collision, `set_texture()` (same file) treats it
|
|
as a genuine cache hit and reuses that entry's SDL surfaces - allocated
|
|
for whatever `(w, h)` was cached before - for an `SDL_memmove` and
|
|
`glTexImage2D` sized to the *current* bitmap's byte count, corrupting
|
|
the result with a buffer overflow or leftover stale pixel data from a
|
|
previous, differently-sized bitmap. Fixed by keying the cache on a
|
|
`std::tuple<void *, int, int>` instead, which `std::map` compares
|
|
field-by-field with no collision risk - a hit now always means an exact
|
|
`(bits, w, h)` match, so the cached surfaces are guaranteed to already
|
|
be sized correctly for the current bitmap.
|
|
|
|
A second, independent bug in the same cache: on a hit, `set_texture()`
|
|
"refreshed" the cached `SDL_Surface` with
|
|
`SDL_memmove(t->bitmap->pixels, bm->bits, ...)` - but `t->bitmap` was
|
|
created by `convert_texture()` via `SDL_CreateRGBSurfaceFrom(bm->bits,
|
|
...)`, which wraps the given pointer rather than copying it, so
|
|
`t->bitmap->pixels` *is* `bm->bits` from whenever this entry was first
|
|
cached. Since a hit means the current call's `bm->bits` is that same
|
|
pointer by definition (it's part of the key), that memmove was always a
|
|
no-op self-copy - it never refreshed anything. `bm->bits` is a recycled
|
|
resource-cache slot reused for different content across draws while its
|
|
address stays the same, so a hit here was never a guarantee the pixels
|
|
underneath were still current, only that this entry's GL texture and
|
|
converted-surface buffers are the right size to reuse. Fixed by
|
|
re-pointing `t->bitmap->pixels`/`pitch` at the current frame's real
|
|
source (`bm->bits`, or the RSD8-decoded buffer) before blitting, instead
|
|
of copying into what was already the same address. Most noticeable with
|
|
content whose per-frame bitmap dimensions vary a lot, such as critter
|
|
animations after an AI upscale mod re-exports each frame at its own
|
|
independently upscaled size (see `modding/README.md` section 5) -
|
|
vanilla assets mostly dodge the first bug because many critter
|
|
animations reuse the same bounding-box size across most of their
|
|
frames, but the second bug affects any critter whose rendering revisits
|
|
the same resource-cache slot across different draws, regardless of mod
|
|
content.
|
|
|
|
### 4.5. Debugging
|
|
|
|
`engine-debug-tools/` holds engine instrumentation that's useful for
|
|
diagnosing rendering bugs but has no place in a normal build - kept
|
|
separate from `engine-patches/` (section 4.4) so it's never silently
|
|
included in `make dist`/`make package`.
|
|
|
|
- `engine-debug-tools/engine-patches/` - patches in the same format and
|
|
numbered the same way as `engine-patches/` (section 4.4's own process
|
|
applies: scratch copy, `patch -p1`, regenerate via `diff -u` rather than
|
|
hand-editing hunks), applied on top of the regular `engine-patches/`
|
|
series, never in place of it. Two patches so far:
|
|
- **`01`** - a debug dump hotkey: pressing **K** in-game dumps every
|
|
frame of the currently/most-recently-drawn critter's animation to
|
|
`dumps/` (created next to the running binary) as raw palette-index
|
|
pixel data - 2-byte LE width, 2-byte LE height, then width\*height
|
|
index bytes, one file per frame, named `id<resource-id>_frame<N>.raw`.
|
|
Deliberately independent of the renderer's own texture/GL state, so a
|
|
frame already corrupted in the dump is a decode-time bug, while a
|
|
frame that's clean in the dump but wrong on screen is a rendering-time
|
|
one.
|
|
- **`02`** - RSD8 decode tracing: `gr_rsd8_convert()`/`gr_rsd8_unpack()`
|
|
(`engine/src/Libraries/2D/Source/RSD/`) print a `[debug rsd8]` summary
|
|
per frame (decode path, source/destination dimensions, bytes written
|
|
versus the bitmap's own area) and a `[debug rsd8 op]` line per RLE
|
|
opcode consumed, gated by a flag `debug_dump_critter_animation()` (01,
|
|
above) sets only around its own decode calls - tracing covers exactly
|
|
the frames the K hotkey dumps, not every RSD8 decode in the game. Each
|
|
frame is also decoded a second time back to back and logged as
|
|
`identical`/`DIFFERS` against the first, to tell a decode result that's
|
|
deterministic given the compressed source (a bad source stream, or a
|
|
decoder bug it triggers) apart from one that depends on something else,
|
|
such as a resource-cache slot being reused mid-decode.
|
|
- `engine-debug-tools/dump2png/` - converts those `.raw` dumps into
|
|
viewable PNGs using the real game palette (the dump format has no
|
|
palette of its own, to keep the in-game dump path from depending on the
|
|
renderer's own palette/SDL state). Builds the same way
|
|
`modding/gen_animgallery.sh` builds `animgallery` - against
|
|
`modding/src/hacked` inside `modding/mdlp-image`'s Go toolchain, since
|
|
neither has a cgo dependency of its own - via
|
|
`engine-debug-tools/build-dump2png.sh <output-path>`.
|
|
|
|
`make dist_debug` builds everything above into `dist/` alongside the
|
|
normal `make dist` output: the engine with `engine-debug-tools/
|
|
engine-patches/` applied in addition to the regular `engine-patches/`
|
|
series, plus `dist/dump2png` and `dist/convert-dumps.sh` (a `dump2png`
|
|
wrapper, same convention as `run-mods.sh` wrapping `run.sh`, pointed at
|
|
`dist/`'s own `res/data` and `dumps/` so no arguments are needed). There's
|
|
one `dist/` - whichever of `dist`/`dist_debug` ran last determines whether
|
|
the debug hotkey, `dump2png` and `convert-dumps.sh` are present; running
|
|
the other target again overwrites it back.
|
|
|
|
```sh
|
|
make dist_debug
|
|
./dist/run.sh # or run-mods.sh - play until the bug shows, press K, quit
|
|
./dist/convert-dumps.sh # writes dist/dumps-png/*.png from dist/dumps/*.raw
|
|
```
|
|
|
|
## 5. Android / Quest build
|
|
|
|
**Prerequisites:**
|
|
|
|
- **Docker** - same build-image as the desktop build (see "4. Desktop
|
|
build" above), plus network access at build time for Gradle/AGP's own
|
|
dependency resolution (the one target that isn't fully offline).
|
|
- **SideQuest or `adb`** (Android Platform Tools) - to sideload the built
|
|
APK onto the headset, and to enable/verify USB debugging; see "5.1.
|
|
Installing and playing" below.
|
|
- Building/debugging natively in Android Studio instead of via `make
|
|
apk` needs its own separate toolchain - see "5.2. Building natively in
|
|
Android Studio" below.
|
|
|
|
`make apk` builds `dist/questshock-<version>-android-arm64.apk` (or
|
|
`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an
|
|
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
|
|
can be sideloaded onto any Android-based VR headset with OpenXR support
|
|
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
|
|
vendor's store. The game's own rendering is unchanged (still a flat, 2D
|
|
render, no stereo 3D scene), but instead of running as a Home-hosted 2D
|
|
panel it's now shown as a single head-tracked quad floating in front of
|
|
the viewer, with a separate menu quad toggled by a controller button and
|
|
driven by a laser-pointer-style aim ray from each hand
|
|
(`android/app/src/main/cpp/xr_input.c`) - point and pull the trigger to
|
|
interact with it, same as the game's own Bluetooth mouse/keyboard input
|
|
otherwise works unchanged.
|
|
|
|
The menu (`MenuOverlay.java`, described in "5.1.1. Menu, controls and
|
|
cheats" below) and the keyboard (`KeyboardOverlay.java`) are two fully
|
|
independent quads, each its own `XrOverlay` instance
|
|
(`android/app/src/main/cpp/xr_overlay.c` - a generic "off-screen Android View
|
|
rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by
|
|
both) with its own swapchain, visibility, and position, so they can be
|
|
shown/hidden/moved independently - the keyboard can stay open while also
|
|
picking something from the menu. The controller's menu button only ever
|
|
opens/closes the menu; the menu's "Keyboard" button only opens the keyboard
|
|
(never touching the menu's own visibility) - the keyboard's own title bar
|
|
holds the only way to close it again.
|
|
|
|
The keyboard is a hand-built, full US-layout key grid (there's no system
|
|
IME to borrow once immersive) that forwards each key press straight to the
|
|
game as real input - non-printable keys (Esc/Enter/Backspace/Tab/arrows/
|
|
F1-F12) as a `SDLActivity.onNativeKeyDown()`/`onNativeKeyUp()` pair, the
|
|
same one a physical Bluetooth keyboard's presses already go through, and
|
|
printable keys (letters, digits, punctuation, Space) via a synthesized
|
|
`SDL_TEXTINPUT` event pushed directly from native code
|
|
(`questshock_native.c`). A Shift key toggles the whole grid between
|
|
lowercase/numbers and uppercase/symbols (doubling as Caps Lock - it stays
|
|
toggled until pressed again), and Ctrl/Alt arm as one-shot modifiers,
|
|
consumed by whichever key is pressed next - meant as a general stand-in
|
|
for keyboard-driven functionality that isn't (yet, or ever) mapped onto
|
|
the controllers, not just a future config screen's text entry.
|
|
|
|
Once opened, the keyboard stays up as a standing input panel while
|
|
actually playing rather than a modal you open and close. Its title bar
|
|
doubles as a drag handle (grab-and-drag with the trigger, handled entirely
|
|
on the native side in `xr_input.c` before it ever reaches
|
|
`KeyboardOverlay`'s own touch dispatch) for repositioning it, and holds
|
|
the Close button that hides it. It's also rendered semi-transparent
|
|
(`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT` in `xr_session.c`,
|
|
set only on the keyboard's layer - the menu launcher stays fully opaque)
|
|
so whatever's behind it - the game quad, mid-play - stays visible while
|
|
it's up.
|
|
|
|
While the menu launcher or keyboard is open, each hand also gets a thin,
|
|
colored laser-beam quad (cyan left, amber right) from the controller to
|
|
wherever its ray currently crosses that panel's plane - a second,
|
|
billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in
|
|
`xr_input.c`), oriented so it reads as a line from the viewer's eye
|
|
regardless of angle, since the flat cross-shaped reticle used while
|
|
aiming at the game quad is drawn directly into the game quad's own
|
|
texture and can't represent a ray traversing real 3D space. Aiming at the
|
|
game quad itself still uses that flat reticle, unchanged. (Only tested on
|
|
Meta Quest so far -
|
|
the steps below use Quest-specific tool names where relevant, but the
|
|
same `adb install` flow applies to any Android headset with USB
|
|
debugging enabled.)
|
|
|
|
### 5.1. Installing and playing
|
|
|
|
1. Install the APK with [SideQuest](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.
|
|
|
|
System Shock is a trademark of its respective owners. Questshock is an
|
|
unofficial fan project and is not affiliated with, endorsed by or supported by
|
|
them.
|
|
|
|
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
|
copyrighted game data owned by their respective rightsholders - they are
|
|
never committed to this repository (see `.gitignore`) and must be
|
|
supplied by each user from their own legitimate purchase.
|
|
|
|
`NOTICE.txt` at the repository root summarizes the above and is bundled
|
|
into the release tarball by `make package` (see "4.1. Packaging a
|
|
distributable build" above) - keep the two in sync.
|