6 Commits

Author SHA1 Message Date
ml 2300d081c3 Add OpenXR bring-up: render the game as a floating quad in an immersive Quest session
build / build (push) Successful in 2m12s
- New android/app/src/main/cpp/xr_session.c (+.h): owns the OpenXR
  instance/session/local-space/swapchain and the per-frame
  xrWaitFrame/xrBeginFrame/xrEndFrame loop, submitting the game's
  existing flat render as a single head-tracked XrCompositionLayerQuad.
  No stereo rendering or controller input yet - that's steps B/C/D of
  the plan.
- 11-android-openxr-cmake.patch: links the OpenXR loader as a build
  dependency, staged into jniLibs the same way as gl4es/SDL2.
  build-image/Dockerfile and prepare-android-project.sh build and stage
  it.
- 12-android-openxr-present.patch: redirects OpenGL.cc's final present
  step (opengl_swap_and_restore / SDLDraw's software path) into the XR
  swapchain FBO instead of the window, on Android only. Two real gl4es
  bugs had to be worked around to get pixels on screen at all:
    - gl4es's own glBindFramebuffer errors on an FBO id it didn't create
      itself, even though the real driver-level bind succeeds - fixed by
      creating the swapchain FBO container via gl4es's own
      glGenFramebuffers/glBindFramebuffer, and only using the real
      (dlsym'd) driver call for attaching OpenXR's foreign swapchain
      texture, which gl4es's own attach can't handle.
    - gl4es's glBegin/glVertexAttrib/glVertex3f immediate-mode emulation
      only captures a fresh per-vertex value for attribute 0 (position,
      driven directly by glVertex3f) - custom attributes like this
      shader's texcoords/light are GLES2's *constant*-attribute API and
      applied once for the whole draw, not per vertex, silently
      collapsing the UI-overlay quad to a single sampled texel. Fixed by
      switching that one draw call to real vertex arrays
      (glVertexAttribPointer/glDrawArrays).
  Also flips the V texcoord to match SDL's top-down row order against
  GL's texture convention, and reuses a persistent texture object
  instead of a fresh gen/upload/delete every frame.
- AndroidManifest.xml: declares the immersive-HMD intent category and
  focus-aware metadata, drops the 2D-panel layout hint.
- README: documents the new OpenXR immersive mode.
2026-07-25 07:25:27 +02:00
ml 0f59898bb6 - AndroidManifest.xml: add android:screenOrientation="landscape" and a
build / build (push) Successful in 2m15s
<layout> defaultWidth/defaultHeight/minWidth/minHeight/gravity hint -
  Quest's Home shell otherwise defaults a freshly-launched 2D panel to a
  portrait shape, cropping the game's 4:3 640x480 content down to a
  sliver.
- The actual root cause of the remaining crop (game rendering into one
  corner of an otherwise correctly-sized panel), found via on-device
  logcat and the new diagnostics below rather than guesswork:
  ChangeScreenSize() (engine/src/MacSrc/ShockBitmap.c) calls
  SDL_SetWindowSize() whenever the game sets its video mode. That's a
  desktop-only operation in effect - Android has no SetWindowSize driver
  hook, so SDL's generic layer instead overwrites its own cached window
  size to the game's internal resolution (640x480) and synthesizes a
  resize event from that, desyncing SDL's notion of the window size from
  the real, unchanged Android surface (e.g. 1600x1200). Both SDL's own
  renderer viewport and the engine's custom GL viewport then scale
  against that corrupted cached size. 10-android-no-window-resize.patch
  skips the desktop-only SDL_SetWindowFullscreen/SetWindowSize/
  SetWindowPosition calls on Android, keeping the legitimate
  SDL_RenderSetLogicalSize/offscreen-bitmap setup.
- 06-android-resize-event.patch: also react to SDL_WINDOWEVENT_RESIZED
  in the engine's event loop, not just SIZE_CHANGED - Android's SDL video
  backend never sends SIZE_CHANGED for surface-driven resizes, only
  RESIZED, an independent gap worth closing regardless of the bug above.
- 08/09-android-logcat-*.patch: route the engine's existing log.c output
  (previously plain fprintf(stderr,...), never actually captured by
  logcat on this build) through __android_log_vprint instead, so every
  existing INFO/DEBUG/WARN/ERROR call site becomes visible for on-device
  debugging. This is what made the diagnostic below (and everything
  since) observable at all.
- 07-android-size-diagnostics.patch: one-time startup log comparing
  SDL_GetWindowSize/SDL_GL_GetDrawableSize/SDL_GetRendererOutputSize -
  the evidence that actually pinned down the SDL_SetWindowSize bug above.
- QuestShockActivity.java: add a diagnostic onSizeChanged() log on
  GameSurface, used to rule out a later Android-side panel relayout as
  the cause before finding the real one.
2026-07-24 06:31:18 +02:00
ml 4e47e0a989 Fix missing-assets detection race, 16 KB page alignment, and Android audio backend
build / build (push) Successful in 2m11s
- QuestShockActivity now actually blocks the native engine from starting
  when game data is missing, closing three gaps found via on-device
  testing: super.onCreate() must run unconditionally first (Android
  throws SuperNotCalledException otherwise); SDLActivity.mBrokenLibraries
  is now set provisionally before the storage-permission check, since
  onWindowFocusChanged() closing the permission dialog could otherwise
  start the engine before the async onRequestPermissionsResult() callback
  ran; and a new GameSurface (SDLSurface subclass) closes the actual gap
  that let the init_popups NULL-deref crash through even with
  mBrokenLibraries set - SDLSurface.surfaceChanged() starts the native
  thread directly without ever checking that flag.
- Force Android to use SDL2's openslES audio backend instead of AAudio
  (android/engine-patches/05-android-audio-driver.patch): AAudio only
  allows one open playback device at a time, but the engine opens two
  (cutscene audio via SDL_OpenAudioDevice, SFX/MIDI via Mix_OpenAudio),
  hitting an assertion failure on real hardware.
- Add 16 KB ELF page-size alignment (-Wl,-z,max-page-size=16384) to every
  Android shared library - the four prebuilts (SDL2, SDL2_mixer,
  fluidsynth-lite, gl4es, in build-image/Dockerfile) and the engine's own
  libmain.so (build.gradle) - matching Google's Play Store requirement
  for Android 15+ and clearing Android Studio's compatibility warning.
- Add a stageEngine Gradle task that automatically re-stages the patched
  engine/ copy and prebuilt libraries before any Android Studio build
  (hooked into preBuild, with proper up-to-date checking), so source/
  patch changes can't silently go stale in the build/android-engine
  scratch copy - previously a manual, easy-to-forget step. Skips
  automatically inside the build-image container so make apk/CI are
  unaffected.
2026-07-23 19:20:22 +02:00
ml ac640762d7 Fix Quest launch crashes, version the APK like the tarball, and support native builds in Android Studio
build / build (push) Successful in 2m13s
- patchelf gl4es's embedded SONAME to libGL.so so AGP's jniLibs packaging
  (which drops any file not literally named "*.so") and the dynamic
  linker's NEEDED-entry resolution (by embedded SONAME, not filename)
  finally agree - fixes the "library \"libGL.so.1\" not found" crash seen
  on real Quest hardware.
- QuestShockActivity now checks that res/data and res/sound exist before
  starting the native engine, showing an explanatory dialog instead of
  crashing on init_popups' unchecked NULL resource load when a fresh
  install has no game data copied in yet.
- dist/questshock-<version>-android-arm64.apk is now versioned from the
  same git-tag-or-dev-placeholder scheme as the desktop tarball
  (build-image/version.sh, shared by both via the Makefile and
  build-apk.sh).
- build-image/prepare-android-project.sh (prep logic extracted out of
  build-apk.sh) can now stage android/ for a native build directly in
  Android Studio (--host-paths), exporting the prebuilt SDL2/SDL2_mixer/
  fluidsynth-lite/gl4es libraries and writing host-resolvable paths,
  instead of only ever building inside the Docker image.
- Corrected GET_ASSETS.txt/GET_ASSETS_QUEST.txt, which wrongly described
  merging res/pc/hd and res/pc/cdrom trees out of the raw installer's
  sshock.kpf - an already-installed copy's res/data res/sound can just
  be copied directly, with extract_assets.sh only needed from the raw
  installer.
2026-07-23 06:42:45 +02:00
ml 3da137da09 rename Shockolate tar ball 2026-07-20 13:51:10 +02:00
ml 3034703994 Adding publishing of the APK
build / build (push) Successful in 1m37s
2026-07-20 13:16:33 +02:00
35 changed files with 1512 additions and 120 deletions
+22 -4
View File
@@ -45,6 +45,7 @@ jobs:
command -v cmake >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: cmake not in PATH" >&2; exit 1; } command -v cmake >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: cmake not in PATH" >&2; exit 1; }
[ -n "${QUESTSHOCK_BUILD_IMAGE:-}" ] || { echo "PREFLIGHT FAIL: QUESTSHOCK_BUILD_IMAGE not set - not running inside the questshock build-image?" >&2; exit 1; } [ -n "${QUESTSHOCK_BUILD_IMAGE:-}" ] || { echo "PREFLIGHT FAIL: QUESTSHOCK_BUILD_IMAGE not set - not running inside the questshock build-image?" >&2; exit 1; }
[ -d /opt/prebuilt/built_sdl ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/built_sdl missing" >&2; exit 1; } [ -d /opt/prebuilt/built_sdl ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/built_sdl missing" >&2; exit 1; }
[ -d /opt/prebuilt/android/gl4es ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/gl4es missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
- name: Build package - name: Build package
run: make package run: make package
@@ -54,17 +55,33 @@ jobs:
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works. # instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: questshock name: shockolate
path: dist/questshock-*-linux-*.tar.gz path: dist/shockolate-*-linux-*.tar.gz
- name: Build Quest APK
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
# this runs build-apk.sh directly instead of trying to docker-run
# the image again - same reasoning as `make package` above.
# Unlike the fully offline engine/package build, this needs
# network access for Gradle/AGP's own dependency resolution - a
# scoped, accepted exception (see build-image/Dockerfile).
run: make apk
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: questshock-quest-apk
path: dist/questshock-*-android-*.apk
- name: Publish to dl.ladkau.de - name: Publish to dl.ladkau.de
# Uploads the tarball over SFTP instead of using # Uploads the tarball and APK over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled). # actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish. # Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
run: | run: |
set -euo pipefail set -euo pipefail
TARBALL="$(ls dist/questshock-*-linux-*.tar.gz)" TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
APK="$(ls dist/questshock-*-android-*.apk)"
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key chmod 600 ~/.ssh/dl_sftp_key
@@ -73,4 +90,5 @@ jobs:
uploader@dl.ladkau.de <<EOF uploader@dl.ladkau.de <<EOF
-mkdir files/questshock -mkdir files/questshock
put $TARBALL files/questshock/$(basename "$TARBALL") put $TARBALL files/questshock/$(basename "$TARBALL")
put $APK files/questshock/$(basename "$APK")
EOF EOF
+3
View File
@@ -1,3 +1,6 @@
# Android studio project files
/android/.idea
# Game assets are not included in the repo # Game assets are not included in the repo
/res/assets/setup_system_shock_enhanced* /res/assets/setup_system_shock_enhanced*
/res/assets/ss_ee/ /res/assets/ss_ee/
+3 -15
View File
@@ -65,7 +65,7 @@ dist: engine assets
@echo "== Done - run $(DIST_DIR)/run.sh to play ==" @echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Builds a versioned, redistributable Linux release tarball at # Builds a versioned, redistributable Linux release tarball at
# dist/questshock-<version>-linux-<arch>.tar.gz - everything needed to # dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
# run except the proprietary game assets (res/GET_ASSETS.txt explains how # run except the proprietary game assets (res/GET_ASSETS.txt explains how
# to get those instead of shipping res/data/, res/sound/). Version # to get those instead of shipping res/data/, res/sound/). Version
# defaults to the current git tag (vX.Y.Z, tag prefix stripped) - push a # defaults to the current git tag (vX.Y.Z, tag prefix stripped) - push a
@@ -74,20 +74,8 @@ dist: engine assets
# placeholder version, with a warning). # placeholder version, with a warning).
package: engine package: engine
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true @git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@V="$(VERSION)"; \ @V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
if [ -z "$$V" ]; then \ PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PKG_NAME="questshock-$$V-linux-$(ARCH)"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \ PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \ echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \ rm -rf "$$PKG_STAGE"; \
+62 -9
View File
@@ -6,6 +6,16 @@ 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), headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original game. a cross-platform port of the original game.
## 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.
## Layout ## Layout
- `engine/` - a vendored snapshot of the Shockolate engine source. Built - `engine/` - a vendored snapshot of the Shockolate engine source. Built
@@ -22,9 +32,9 @@ a cross-platform port of the original game.
- `res/run.sh` - the launcher script, copied into `dist/` on build. - `res/run.sh` - the launcher script, copied into `dist/` on build.
- `Makefile` - assembles `dist/`, a self-contained runnable copy of the - `Makefile` - assembles `dist/`, a self-contained runnable copy of the
game, out of the compiled engine and the extracted assets. Also builds game, out of the compiled engine and the extracted assets. Also builds
`dist/questshock-<version>-linux-<arch>.tar.gz`, a redistributable `dist/shockolate-<version>-linux-<arch>.tar.gz`, a redistributable
package that omits the proprietary game assets (`make package`), and package that omits the proprietary game assets (`make package`), and
`dist/questshock-debug.apk` for the Quest (`make apk`). `dist/questshock-<version>-android-arm64.apk` for the Quest (`make apk`).
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project). - `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
`android/engine-patches/` holds the one small patch needed to build `android/engine-patches/` holds the one small patch needed to build
`engine/` as an Android shared library instead of a desktop executable `engine/` as an Android shared library instead of a desktop executable
@@ -64,14 +74,22 @@ game data lives inside it in a zip-format `sshock.kpf`) into
they aren't installed locally it falls back to running the extraction in they aren't installed locally it falls back to running the extraction in
a throwaway Docker container instead. 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.
## Packaging ## Packaging
`make package` builds `dist/questshock-<version>-linux-<arch>.tar.gz`: the `make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the
compiled binary, its runtime libraries, shaders, a default MIDI compiled binary, its runtime libraries, shaders, a default MIDI
soundfont, license information, and `res/GET_ASSETS.txt` in place of the soundfont, license information, and `res/GET_ASSETS.txt` in place of the
actual game data (which the tarball never includes). Version comes from actual game data (which the tarball never includes). Version comes from
the current git tag (push a `vX.Y.Z` tag to drive a release); without one 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. it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (below)
is versioned identically, via the same `build-image/version.sh`.
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
package on every push, using the build-image as its container (so no package on every push, using the build-image as its container (so no
@@ -80,11 +98,14 @@ resulting tarball to dl.ladkau.de.
## Playing on Meta Quest ## Playing on Meta Quest
`make apk` builds `dist/questshock-debug.apk` - a plain (non-VR) Android `make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
app that runs as a flat, floating panel in the Quest's Home environment, immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`): the
same as any other sideloaded Android app. It's not a head-tracked 6DoF VR game's own rendering is unchanged (still a flat, 2D render, no stereo 3D
port (that's a much larger, separate undertaking); play with a Bluetooth scene), but instead of running as a Home-hosted 2D panel it's now shown as
mouse/keyboard connected to the headset. a single head-tracked quad floating in front of the viewer in an otherwise
empty space. There's no controller-driven interaction yet (that's ongoing
work - a laser-pointer-driven menu and on-screen keyboard); for now, play
with a Bluetooth mouse/keyboard connected to the headset same as before.
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb 1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
install`). install`).
@@ -99,6 +120,34 @@ mouse/keyboard connected to the headset.
`/sdcard/questshock/res/`. `/sdcard/questshock/res/`.
4. Launch it again. 4. Launch it again.
### 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
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
```
This stages everything `make apk` normally stages (a scratch, patched
copy of `engine/`; the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/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.
Re-run it whenever `engine/`, `android/engine-patches/`, or the
prebuilt-library versions in `build-image/Dockerfile` change.
Then open `android/` as a project in Android Studio (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.
## License ## License
The original tooling in this repository (the Docker build image, build The original tooling in this repository (the Docker build image, build
@@ -115,6 +164,10 @@ The Quest build also bundles [GL4ES](https://github.com/ptitSeb/gl4es)
image), which translates the engine's desktop-style OpenGL calls into image), which translates the engine's desktop-style OpenGL calls into
GLES/EGL and is MIT-licensed. 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 The vendored engine snapshot in `engine/` is
[Shockolate](https://github.com/Interrupt/systemshock), which is licensed [Shockolate](https://github.com/Interrupt/systemshock), which is licensed
under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged
+87 -12
View File
@@ -1,14 +1,73 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
// Path to the (patched, at build time - see build-apk.sh and // Paths written by build-image/prepare-android-project.sh (called by
// ../engine-patches/) scratch copy of engine/, written by build-apk.sh // build-apk.sh, or standalone with --host-paths to prep for a native
// since it's only known at build time, not something a checked-in // build in Android Studio - see README) since they're only known at
// build.gradle can hardcode. // prep/build time, not something a checked-in build.gradle can hardcode.
// engineDir is the scratch, patched copy of engine/; prebuiltDir is the
// Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts - both point
// into this container's own paths for build-apk.sh's own gradlew call,
// or host-resolvable paths (plus a host-side export of prebuiltDir, since
// /opt/prebuilt/android only exists in the image) when prepared with
// --host-paths for Android Studio.
def engineProps = new Properties() def engineProps = new Properties()
file("${projectDir}/../engine.properties").withInputStream { engineProps.load(it) } file("${projectDir}/../engine.properties").withInputStream { engineProps.load(it) }
def engineDir = engineProps.getProperty('engineDir') def engineDir = engineProps.getProperty('engineDir')
if (engineDir == null) { if (engineDir == null) {
throw new GradleException("android/engine.properties is missing 'engineDir' - run via build-apk.sh, not gradlew directly") throw new GradleException("android/engine.properties is missing 'engineDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
}
def prebuiltDir = engineProps.getProperty('prebuiltDir')
if (prebuiltDir == null) {
throw new GradleException("android/engine.properties is missing 'prebuiltDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
}
// Set by build-apk.sh (see build-image/version.sh, shared with the
// desktop tarball's versioning) via -PquestshockVersionName/Code. Default
// here only covers a direct, unsupported `gradlew` invocation.
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
// Auto-refreshes the staged, patched engine/ copy and prebuilt libraries
// (see build-image/prepare-android-project.sh --host-paths, and the
// engineDir/prebuiltDir properties read above) so a plain Android Studio
// build/run can never silently compile against a stale scratch copy after
// engine/ or android/engine-patches/ change - previously a manual, easy to
// forget re-run. inputs/outputs are declared so Gradle skips the (Docker-
// invoking, not free) step entirely when nothing relevant actually changed,
// keeping pure-Java edit/run cycles fast.
def repoRoot = file("${projectDir}/../..")
def stageEngineTask = tasks.register("stageEngine", Exec) {
group = "build setup"
description = "Refreshes the patched engine/ scratch copy and prebuilt libraries for a native Android Studio build (build-image/prepare-android-project.sh --host-paths)."
workingDir repoRoot
commandLine "./run-image.sh", "bash", "build-image/prepare-android-project.sh", "--host-paths"
// /opt/prebuilt/android only ever exists inside the build-image
// container itself (baked in at image-build time, never on the host -
// see build-image/Dockerfile). Its presence means this build is
// build-apk.sh's own gradlew call, running INSIDE that container, which
// already staged everything itself in container mode before invoking
// gradlew - re-running this task there would try to `docker run` from
// inside a container with no docker socket, breaking make apk/CI. Only
// a genuine host-side Android Studio build (where this path is absent)
// needs this task.
onlyIf { !file('/opt/prebuilt/android').isDirectory() }
inputs.dir("${repoRoot}/engine")
inputs.dir("${projectDir}/../engine-patches")
inputs.file("${repoRoot}/build-image/Dockerfile")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-prebuilt")
outputs.file("${projectDir}/../engine.properties")
}
// preBuild is what every variant's compile/native-build tasks already
// transitively depend on, regardless of AGP version's exact CMake task
// naming - the simplest reliable hook to run before any of them.
afterEvaluate {
tasks.named("preBuild").configure {
dependsOn stageEngineTask
}
} }
android { android {
@@ -31,8 +90,8 @@ android {
// targets - not just whatever's the current API level today. // targets - not just whatever's the current API level today.
minSdkVersion 24 minSdkVersion 24
targetSdkVersion 29 targetSdkVersion 29
versionCode 1 versionCode questshockVersionCode
versionName "1.0" versionName questshockVersionName
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -42,21 +101,37 @@ android {
// config, unlike the desktop build's older bundled copy); // config, unlike the desktop build's older bundled copy);
// SDL2_mixer and FluidSynth via the same build_ext/ BUNDLED // SDL2_mixer and FluidSynth via the same build_ext/ BUNDLED
// convention the desktop build already uses (populated in the // convention the desktop build already uses (populated in the
// scratch engine copy by build-apk.sh from // scratch engine copy by prepare-android-project.sh from
// /opt/prebuilt/android/*). // prebuiltDir). ANDROID_PREBUILT_DIR is also read directly by
// android/engine-patches/02-android-opengl-es.patch, for gl4es.
// The CMAKE_FIND_ROOT_PATH* overrides below are needed // The CMAKE_FIND_ROOT_PATH* overrides below are needed
// because the NDK toolchain file restricts find_package/ // because the NDK toolchain file restricts find_package/
// find_path/find_library to its own sysroot by default, // find_path/find_library to its own sysroot by default,
// which would otherwise miss our custom-installed SDL2 (see // which would otherwise miss our custom-installed SDL2 (see
// build-image/Dockerfile's SDL2_mixer build, which hit the // build-image/Dockerfile's SDL2_mixer build, which hit the
// exact same thing). // exact same thing).
// -Wl,-z,max-page-size=16384: NDK 26 doesn't 16 KB-align ELF
// LOAD segments by default (only automatic in NDK 28+) - see
// build-image/Dockerfile's ANDROID_16KB_LDFLAGS, which does
// the same for the prebuilt SDL2/SDL2_mixer/fluidsynth-lite/
// gl4es .so's this links against.
// ANDROID_EXTRA_SOURCES is a semicolon-separated CMake list,
// not shell-split - AGP passes each "arguments" string
// straight through as one argv element to cmake, so the
// ';' below survives intact. ANDROID_EXTRA_INCLUDE_DIRS lets
// engine/'s own OpenGL.cc find "xr_session.h" (see
// android/engine-patches/11-android-openxr-cmake.patch,
// which adds it to include_directories()).
arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \
"-DCMAKE_PREFIX_PATH=/opt/prebuilt/android/sdl2", \ "-DANDROID_PREBUILT_DIR=${prebuiltDir}", \
"-DCMAKE_FIND_ROOT_PATH=/opt/prebuilt/android/sdl2", \ "-DANDROID_EXTRA_INCLUDE_DIRS=${projectDir}/src/main/cpp", \
"-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \
"-DCMAKE_FIND_ROOT_PATH=${prebuiltDir}/sdl2", \
"-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c" "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c"
abiFilters 'arm64-v8a' abiFilters 'arm64-v8a'
} }
} }
+20
View File
@@ -12,6 +12,11 @@
<!-- External mouse input events --> <!-- External mouse input events -->
<uses-feature android:name="android.hardware.type.pc" android:required="false" /> <uses-feature android:name="android.hardware.type.pc" android:required="false" />
<!-- Immersive OpenXR app now (see android/app/src/main/cpp/xr_session.c),
not a 2D Home panel - vr.headtracking is what actually declares that
to Horizon OS. -->
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
<!-- Plain, unrestricted /sdcard access for the res/data,res/sound the <!-- Plain, unrestricted /sdcard access for the res/data,res/sound the
user drops in and the shaders/soundfont this app extracts there on user drops in and the shaders/soundfont this app extracts there on
first run - see res/GET_ASSETS_QUEST.txt and QuestShockActivity. first run - see res/GET_ASSETS_QUEST.txt and QuestShockActivity.
@@ -28,17 +33,32 @@
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
android:hardwareAccelerated="true" > android:hardwareAccelerated="true" >
<!-- Tells Horizon OS this activity wants the compositor for itself
(immersive VR), not the Home 2D-panel path - required for an
OpenXR session to actually start. -->
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<activity android:name="de.ladkau.questshock.QuestShockActivity" <activity android:name="de.ladkau.questshock.QuestShockActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:alwaysRetainTaskState="true" android:alwaysRetainTaskState="true"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation" android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:preferMinimalPostProcessing="true" android:preferMinimalPostProcessing="true"
android:screenOrientation="landscape"
android:exported="true" android:exported="true"
> >
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<!-- The vendor-neutral, Khronos-defined way to declare an
immersive OpenXR entry point (vs. e.g. Meta's own
com.oculus.intent.category.VR) - see this project's
"favor OpenXR" rule in the README. If Horizon OS still
launches this as a flat panel on-device, the next thing
to try is adding a com.oculus.supportedDevices meta-data
listing the target Quest models - not confirmed
necessary from static analysis alone. -->
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
+456
View File
@@ -0,0 +1,456 @@
#include "xr_session.h"
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <SDL.h>
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Fixed pose/size for the single game quad - 2m in front of the local space
// origin, sized to preserve the game's own aspect ratio at a comfortable
// visual angle. No per-eye view/projection work is needed for a quad layer
// at all - the compositor handles reprojecting it per eye itself, which is
// exactly why this milestone doesn't need xrLocateViews or any stereo
// rendering (see the plan's step A notes).
#define QUAD_DISTANCE_METERS 2.0f
#define QUAD_WIDTH_METERS 1.6f
// Up to this many swapchain images/FBOs - real runtimes report small counts
// (2-4); this is just a fixed upper bound for the cache arrays below.
#define MAX_SWAPCHAIN_IMAGES 8
static XrInstance g_instance = XR_NULL_HANDLE;
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
static XrSession g_session = XR_NULL_HANDLE;
static XrSpace g_local_space = XR_NULL_HANDLE;
static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// Distinct from g_session_state - true from a successful xrBeginSession
// until xrEndSession/loss/exit. Many runtimes only advance the *state*
// (e.g. READY -> SYNCHRONIZED -> VISIBLE -> FOCUSED) in response to the
// app actually calling xrWaitFrame/xrBeginFrame/xrEndFrame continuously
// after xrBeginSession - gating frame submission on already having
// reached SYNCHRONIZED+ (as this used to) is a chicken-and-egg deadlock,
// since the runtime never progresses past READY without the frame loop
// running in the first place. Frame calls just need the session to have
// begun, per the standard OpenXR sample pattern - not any particular
// later state.
static bool g_session_running = false;
static XrSwapchain g_game_swapchain = XR_NULL_HANDLE;
static int g_game_width = 0;
static int g_game_height = 0;
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
static uint32_t g_swapchain_image_count = 0;
static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false;
static bool g_have_acquired_image = false;
// This file's GL calls otherwise resolve to gl4es (the only GL symbol
// provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), which is fine for anything shared with the
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
// hands us are real driver texture objects gl4es never created itself,
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
// tracked metadata for. So the FBO *container* is created via gl4es's own
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
// own and its own per-frame glBindFramebuffer succeeds), while the
// texture-attach step - the specifically foreign part - goes through the
// real driver directly, via dlsym against libGLESv2.so.
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
static bool xr_load_real_gles(void) {
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glCheckFramebufferStatus =
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
return real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
}
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
if (g_instance != XR_NULL_HANDLE)
xrResultToString(g_instance, result, resultString);
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
return false;
}
static PFN_xrVoidFunction xr_get_proc(const char *name) {
PFN_xrVoidFunction fn = NULL;
// xrGetInstanceProcAddr itself works with a NULL instance for the
// handful of functions (like xrInitializeLoaderKHR) that must be called
// before an instance exists.
if (!xr_check(xrGetInstanceProcAddr(g_instance, name, &fn), name))
return NULL;
return fn;
}
// The Android OpenXR loader needs the JavaVM/context before anything else -
// separate from XR_KHR_android_create_instance below, which only affects
// xrCreateInstance itself. Skipping this is a common cause of
// xrCreateInstance silently failing to find a runtime on Android.
static bool xr_initialize_android_loader(JavaVM *vm, jobject activity) {
PFN_xrInitializeLoaderKHR initializeLoader =
(PFN_xrInitializeLoaderKHR)xr_get_proc("xrInitializeLoaderKHR");
if (initializeLoader == NULL) {
LOGE("XR: xrInitializeLoaderKHR not available - no Android OpenXR loader present?");
return false;
}
XrLoaderInitInfoAndroidKHR loaderInitInfo = {XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loaderInitInfo.applicationVM = vm;
loaderInitInfo.applicationContext = activity;
return xr_check(initializeLoader((const XrLoaderInitInfoBaseHeaderKHR *)&loaderInitInfo),
"xrInitializeLoaderKHR");
}
// eglGetCurrentDisplay()/eglGetCurrentContext() hand us the display/context,
// but not the EGLConfig they were created with - eglQueryContext's
// EGL_CONFIG_ID plus eglChooseConfig is the standard way to recover it (the
// same trick Khronos' own hello_xr sample uses).
static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext context) {
EGLint configId = 0;
eglQueryContext(display, context, EGL_CONFIG_ID, &configId);
EGLint attribs[] = {EGL_CONFIG_ID, configId, EGL_NONE};
EGLConfig config = NULL;
EGLint numConfigs = 0;
eglChooseConfig(display, attribs, &config, 1, &numConfigs);
return config;
}
static bool xr_create_instance_and_session(int game_width, int game_height) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: no JNIEnv/Activity from SDL - can't init OpenXR");
return false;
}
JavaVM *vm = NULL;
(*env)->GetJavaVM(env, &vm);
if (!xr_initialize_android_loader(vm, activity))
return false;
const char *extensions[] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
};
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = vm;
androidInfo.applicationActivity = activity;
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
createInfo.next = &androidInfo;
createInfo.enabledExtensionCount = sizeof(extensions) / sizeof(extensions[0]);
createInfo.enabledExtensionNames = extensions;
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
XR_MAX_APPLICATION_NAME_SIZE - 1);
strncpy(createInfo.applicationInfo.engineName, "Shockolate", XR_MAX_ENGINE_NAME_SIZE - 1);
createInfo.applicationInfo.applicationVersion = 1;
createInfo.applicationInfo.engineVersion = 1;
createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION;
if (!xr_check(xrCreateInstance(&createInfo, &g_instance), "xrCreateInstance"))
return false;
XrSystemGetInfo systemInfo = {XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
if (!xr_check(xrGetSystem(g_instance, &systemInfo, &g_system_id), "xrGetSystem"))
return false;
// Required by spec before creating an OpenGL ES-backed session, even
// though we don't gate on the returned min/max API version here.
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR");
if (getGLESRequirements == NULL)
return false;
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
"xrGetOpenGLESGraphicsRequirementsKHR"))
return false;
EGLDisplay display = eglGetCurrentDisplay();
EGLContext context = eglGetCurrentContext();
if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT) {
LOGE("XR: no current EGL display/context - call xr_init() after init_opengl()");
return false;
}
XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding = {
XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
graphicsBinding.display = display;
graphicsBinding.config = xr_get_current_egl_config(display, context);
graphicsBinding.context = context;
XrSessionCreateInfo sessionInfo = {XR_TYPE_SESSION_CREATE_INFO};
sessionInfo.next = &graphicsBinding;
sessionInfo.systemId = g_system_id;
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
return false;
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
if (!xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_local_space),
"xrCreateReferenceSpace"))
return false;
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
// data conventionally is) and get uploaded/sampled as plain linear
// GL_RGBA with no decode step anywhere in this path, matching the
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
// would auto-gamma-encode on write and double-encode already-encoded
// data.
uint32_t formatCount = 0;
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
xrEnumerateSwapchainFormats(g_session, formatCount, &formatCount, formats);
int64_t chosenFormat = formats[0];
for (uint32_t i = 0; i < formatCount; i++) {
LOGI("XR: swapchain format[%u] = 0x%llx", i, (unsigned long long)formats[i]);
if (formats[i] == GL_RGBA8) {
chosenFormat = GL_RGBA8;
break;
}
}
free(formats);
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
swapchainInfo.format = chosenFormat;
swapchainInfo.sampleCount = 1;
swapchainInfo.width = (uint32_t)game_width;
swapchainInfo.height = (uint32_t)game_height;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_game_swapchain),
"xrCreateSwapchain"))
return false;
g_game_width = game_width;
g_game_height = game_height;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(g_game_swapchain, 0, &imageCount, NULL);
if (imageCount > MAX_SWAPCHAIN_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
MAX_SWAPCHAIN_IMAGES);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an
// uninitialized stack array would leave it as garbage.
XrSwapchainImageOpenGLESKHR images[MAX_SWAPCHAIN_IMAGES] = {0};
for (uint32_t i = 0; i < imageCount; i++)
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (!xr_check(xrEnumerateSwapchainImages(g_game_swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
g_swapchain_image_count = imageCount;
if (!xr_load_real_gles()) {
LOGE("XR: couldn't resolve real GLES FBO functions via dlsym");
return false;
}
// Wraps each swapchain-provided texture in its own framebuffer, matching
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
// without a depth/stencil attachment, since the final composite draw
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
// gl4es's own (linked, not dlsym'd) - see the comment above
// real_glFramebufferTexture2D for why.
bool all_complete = true;
for (uint32_t i = 0; i < imageCount; i++) {
glGenFramebuffers(1, &g_swapchain_fbos[i]);
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[i]);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
images[i].image, 0);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (!all_complete)
return false;
LOGI("XR: instance/session/swapchain ready (%dx%d, %u images)", game_width, game_height,
imageCount);
return true;
}
bool xr_init(int game_width, int game_height) {
if (!xr_create_instance_and_session(game_width, game_height)) {
LOGE("XR: bring-up failed - falling back to the normal window present path");
xr_shutdown();
return false;
}
return true;
}
void xr_poll_events(void) {
if (g_instance == XR_NULL_HANDLE)
return;
while (true) {
XrEventDataBuffer event = {XR_TYPE_EVENT_DATA_BUFFER};
XrResult result = xrPollEvent(g_instance, &event);
if (result == XR_EVENT_UNAVAILABLE)
break;
if (!xr_check(result, "xrPollEvent"))
break;
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
XrEventDataSessionStateChanged *stateEvent = (XrEventDataSessionStateChanged *)&event;
g_session_state = stateEvent->state;
LOGI("XR: session state -> %d", (int)g_session_state);
if (g_session_state == XR_SESSION_STATE_READY) {
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
} else if (g_session_state == XR_SESSION_STATE_STOPPING) {
xr_check(xrEndSession(g_session), "xrEndSession");
g_session_running = false;
} else if (g_session_state == XR_SESSION_STATE_EXITING ||
g_session_state == XR_SESSION_STATE_LOSS_PENDING) {
g_session_running = false;
}
}
}
}
bool xr_is_session_running(void) { return g_session_running; }
bool xr_frame_begin(void) {
g_have_acquired_image = false;
if (!xr_is_session_running())
return false;
XrFrameWaitInfo waitInfo = {XR_TYPE_FRAME_WAIT_INFO};
XrFrameState frameState = {XR_TYPE_FRAME_STATE};
if (!xr_check(xrWaitFrame(g_session, &waitInfo, &frameState), "xrWaitFrame"))
return false;
g_predicted_display_time = frameState.predictedDisplayTime;
g_frame_should_render = frameState.shouldRender;
XrFrameBeginInfo beginInfo = {XR_TYPE_FRAME_BEGIN_INFO};
if (!xr_check(xrBeginFrame(g_session, &beginInfo), "xrBeginFrame"))
return false;
if (!g_frame_should_render)
return false;
uint32_t imageIndex = 0;
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if (!xr_check(xrAcquireSwapchainImage(g_game_swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(xrWaitSwapchainImage(g_game_swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id
// gl4es itself created (see xr_create_instance_and_session()), so its
// own "current FBO" bookkeeping updates correctly and its immediate-
// mode draw calls in android_draw_surface_as_quad() land in the right
// place.
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]);
glViewport(0, 0, g_game_width, g_game_height);
g_have_acquired_image = true;
return true;
}
void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
if (g_have_acquired_image) {
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(xrReleaseSwapchainImage(g_game_swapchain, &releaseInfo),
"xrReleaseSwapchainImage");
}
if (!xr_is_session_running())
return;
XrCompositionLayerQuad quad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
quad.space = g_local_space;
quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
quad.subImage.swapchain = g_game_swapchain;
quad.subImage.imageRect.extent.width = g_game_width;
quad.subImage.imageRect.extent.height = g_game_height;
quad.pose.orientation.w = 1.0f;
quad.pose.position.z = -QUAD_DISTANCE_METERS;
quad.size.width = QUAD_WIDTH_METERS;
quad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width;
const XrCompositionLayerBaseHeader *layers[1] = {(XrCompositionLayerBaseHeader *)&quad};
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
endInfo.layerCount = g_have_acquired_image ? 1 : 0;
endInfo.layers = g_have_acquired_image ? layers : NULL;
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
}
void xr_shutdown(void) {
for (uint32_t i = 0; i < g_swapchain_image_count; i++) {
if (g_swapchain_fbos[i] != 0)
glDeleteFramebuffers(1, &g_swapchain_fbos[i]);
}
g_swapchain_image_count = 0;
if (g_game_swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(g_game_swapchain);
g_game_swapchain = XR_NULL_HANDLE;
if (g_local_space != XR_NULL_HANDLE)
xrDestroySpace(g_local_space);
g_local_space = XR_NULL_HANDLE;
if (g_session != XR_NULL_HANDLE)
xrDestroySession(g_session);
g_session = XR_NULL_HANDLE;
if (g_instance != XR_NULL_HANDLE)
xrDestroyInstance(g_instance);
g_instance = XR_NULL_HANDLE;
g_session_state = XR_SESSION_STATE_UNKNOWN;
g_session_running = false;
}
+56
View File
@@ -0,0 +1,56 @@
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
// game's own rendering untouched (see android/engine-patches/
// 12-android-openxr-present.patch) - this module only owns the OpenXR
// instance/session/swapchain and the final "submit a quad layer instead of
// presenting to a window" step. No stereo rendering, no controller input
// yet (see the plan's steps B/C/D for those) - the game composite is shown
// as a single flat quad floating in front of the viewer.
#ifndef QUESTSHOCK_XR_SESSION_H
#define QUESTSHOCK_XR_SESSION_H
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Call once, right after init_opengl() (OpenGL.cc) has created and made
// current the GL context SDL/gl4es already use - creates the OpenXR
// instance/session sharing that same EGL display/context, plus one
// swapchain sized to the game's logical resolution (game_width/height, i.e.
// grd_cap->w/h - see Shock.c's InitSDL()). Returns false if OpenXR bring-up
// failed (e.g. no runtime installed) - callers should fall back to the
// existing window-present path in that case.
bool xr_init(int game_width, int game_height);
// Pumps XR session-state events. Call once per frame, before
// xr_frame_begin(). Must still be called even when xr_init() returned
// false (no-op in that case).
void xr_poll_events(void);
// True once the session has reached a state where frames are expected
// (XR_SESSION_STATE_SYNCHRONIZED or later) - i.e. it's safe/required to
// start calling xr_frame_begin()/xr_frame_end() each frame.
bool xr_is_session_running(void);
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
// this frame rendered, acquires the next swapchain image and binds its
// framebuffer as the current render target - ready for the caller to draw
// into exactly as it would have drawn to the default framebuffer. Returns
// true if the caller should draw this frame; xr_frame_end() must be called
// unconditionally afterward either way (a begun XR frame must always be
// ended, rendered or not).
bool xr_frame_begin(void);
// Releases the swapchain image (if one was acquired this frame) and
// submits it as a single XrCompositionLayerQuad positioned in front of the
// local reference space's origin, then ends the XR frame.
void xr_frame_end(void);
void xr_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
@@ -1,10 +1,15 @@
package de.ladkau.questshock; package de.ladkau.questshock;
import android.Manifest; import android.Manifest;
import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log; import android.util.Log;
import android.view.SurfaceHolder;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@@ -12,16 +17,26 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/** /**
* Everything here runs before super.onCreate() (which is what loads the * Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
* native libraries and eventually calls Shockolate's own main()) - see * file I/O ("res/data/...", confirmed via grep - none of it goes through
* android/engine-patches/ for why: engine/ itself is never modified, so * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* this Java-side setup is the only place left to prepare * doesn't apply here) expects to find it - see android/engine-patches/ for
* /sdcard/questshock/ the way Shockolate's plain relative-path file I/O * why this is done from Java instead of patching engine/ itself, which is
* ("res/data/...", confirmed via grep - none of it goes through SDL_RWops, * never modified.
* so Android's asset-manager fallback for SDL_RWFromFile doesn't apply *
* here) expects to find it. * super.onCreate() (SDLActivity's) must always run first and unconditionally
* - Android throws SuperNotCalledException otherwise, checked right after
* onCreate() returns, regardless of what this subclass does afterwards.
* SDLActivity's own onCreate() only loads libraries, sets up JNI and the
* surface - the actual native SDL_main thread doesn't start until later, from
* one of several lifecycle paths (onResume(), onWindowFocusChanged(), and
* SDLSurface.surfaceChanged() - see GameSurface below for why that last one
* needs its own fix) - so it's safe to do our own checks (and set
* SDLActivity.mBrokenLibraries, which most - but not all - of those paths
* gate on) afterwards.
*/ */
public class QuestShockActivity extends SDLActivity { public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock"; private static final String TAG = "QuestShock";
@@ -44,8 +59,6 @@ public class QuestShockActivity extends SDLActivity {
// has no public chdir() (confirmed against the actual API 34 stub jar). // has no public chdir() (confirmed against the actual API 34 stub jar).
private static native void nativeChdir(String path); private static native void nativeChdir(String path);
private Bundle mSavedInstanceState;
@Override @Override
protected String[] getLibraries() { protected String[] getLibraries() {
return new String[] { return new String[] {
@@ -56,9 +69,66 @@ public class QuestShockActivity extends SDLActivity {
}; };
} }
// SDLSurface.surfaceChanged() (org/libsdl/app/, vendored from SDL2's own
// template) starts the native SDL thread directly - unlike onResume()/
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
// first. Since surfaceChanged() fires on essentially every launch
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
// setUpGameDirAndContinue()) does NOT actually stop the engine from
// starting - confirmed on-device: the missing-assets crash still happened
// with mBrokenLibraries set, from exactly this path. Route through a
// subclass that adds the missing check instead of patching the vendored
// file directly.
private static class GameSurface extends SDLSurface {
GameSurface(Context context) {
super(context);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
if (SDLActivity.mBrokenLibraries) {
return;
}
super.surfaceChanged(holder, format, width, height);
}
// Diagnostic only (see OpenGL.cc's matching log) - Android's real,
// legitimate signal for "this View's laid-out size actually
// changed" is onSizeChanged(), fired by the framework itself, not
// something we have to poll or guess about. If Quest's Home shell
// settles a freshly-launched panel into its final <layout> size via
// a genuine later layout pass, this is where that would show up -
// confirming whether a real event exists to hook, before writing
// any fix around it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.i(TAG, "GameSurface.onSizeChanged() " + oldw + "x" + oldh + " -> " + w + "x" + h);
}
}
@Override
protected SDLSurface createSDLSurface(Context context) {
return new GameSurface(context);
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
mSavedInstanceState = savedInstanceState; super.onCreate(savedInstanceState);
if (SDLActivity.mBrokenLibraries) {
// SDLActivity's own onCreate() already showed its "SDL Error"
// dialog for this - nothing left for us to do.
return;
}
// Provisionally block the native engine from starting - cleared only
// once setUpGameDirAndContinue() confirms game data is present. Must
// happen before requestPermissions() below: the storage-permission
// dialog closing can fire onWindowFocusChanged(true) - which starts
// the native SDLThread - before the async onRequestPermissionsResult()
// callback (which is what actually calls setUpGameDirAndContinue())
// gets a chance to run, so mBrokenLibraries has to already be true
// going into that race, not set afterwards.
SDLActivity.mBrokenLibraries = true;
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) { != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, ActivityCompat.requestPermissions(this,
@@ -96,12 +166,67 @@ public class QuestShockActivity extends SDLActivity {
copyAssetFile("GET_ASSETS_QUEST.txt", marker); copyAssetFile("GET_ASSETS_QUEST.txt", marker);
} }
// Shockolate's engine/ is never patched to check this itself (see the
// class doc above) - it just does plain fopen("res/data/...", ...)
// and, on a fresh install with no game data copied in yet, that
// fails deep inside startup (init_popups(), which doesn't NULL-check
// the load) as a hard native crash instead of a message. Catch the
// missing-data case here instead, before Shockolate's native
// SDL_main ever starts.
if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) {
// mBrokenLibraries is already true (set in onCreate()) - leave it
// that way. Every path that starts the native SDL_main thread
// (onWindowFocusChanged(), resumeNativeThread(), etc.) already
// checks this flag before doing anything native, so this
// reliably prevents the engine from starting without having to
// duplicate all of SDLActivity's own lifecycle guards ourselves.
showMissingAssetsDialog();
return;
}
// chdir() is process-wide, not per-thread - already in effect for // chdir() is process-wide, not per-thread - already in effect for
// every thread (including the one that will run Shockolate's own // every thread (including the one that will run Shockolate's own
// SDL_main) by the time super.onCreate() below starts it. // SDL_main) by the time the native thread actually starts. Done
// before clearing mBrokenLibraries below so the engine can never
// start pre-chdir.
nativeChdir(GAME_DIR); nativeChdir(GAME_DIR);
super.onCreate(mSavedInstanceState); // Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
}
private static boolean isNonEmptyDir(File dir) {
String[] entries = dir.list();
return entries != null && entries.length > 0;
}
// Same single-button, non-cancelable pattern as SDLActivity's own
// "broken libraries" dialog (org/libsdl/app/SDLActivity.java) - there's
// no game to start without this data, so the only way forward is to
// close, copy the assets, and relaunch. Message text mirrors
// res/assets/GET_ASSETS_QUEST.txt (also extracted to GAME_DIR) so the
// user isn't sent hunting for a second file just to read it the first
// time - it points back there at the end in case they need it again.
private void showMissingAssetsDialog() {
new AlertDialog.Builder(this)
.setTitle("Game data missing")
.setMessage("This app does not include System Shock's game data - it's "
+ "copyrighted, proprietary content that can't be redistributed. To "
+ "play, you need to own a copy of System Shock: Enhanced Edition (e.g. "
+ "from gog.com), installed (on Windows, or via Wine/Proton on Linux).\n\n"
+ "Using SideQuest (or any MTP file browser) with your Quest connected, "
+ "copy its res/data/ and res/sound/ folders directly into:\n"
+ GAME_DIR + "/res/data/\n"
+ GAME_DIR + "/res/sound/\n"
+ "They already contain everything needed, in the right layout - no "
+ "merging or extraction required.\n\n"
+ "Then relaunch.\n\n"
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
+ "questshock folder on this device, if you need to read them again.)")
.setCancelable(false)
.setPositiveButton("Exit", (dialog, which) -> finish())
.create()
.show();
} }
private void copyAssetFile(String assetPath, File dest) { private void copyAssetFile(String assetPath, File dest) {
@@ -12,8 +12,8 @@
if(ENABLE_OPENGL) if(ENABLE_OPENGL)
- find_package(OpenGL REQUIRED) - find_package(OpenGL REQUIRED)
+ if(ANDROID) + if(ANDROID)
+ set(OPENGL_INCLUDE_DIRS /opt/prebuilt/android/gl4es/include) + set(OPENGL_INCLUDE_DIRS ${ANDROID_PREBUILT_DIR}/gl4es/include)
+ set(OPENGL_LIBRARIES /opt/prebuilt/android/gl4es/lib/libGL.so) + set(OPENGL_LIBRARIES ${ANDROID_PREBUILT_DIR}/gl4es/lib/libGL.so)
+ else() + else()
+ find_package(OpenGL REQUIRED) + find_package(OpenGL REQUIRED)
+ endif() + endif()
@@ -0,0 +1,20 @@
--- a/src/MacSrc/Shock.c
+++ b/src/MacSrc/Shock.c
@@ -162,6 +162,17 @@
void InitSDL() {
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
+#ifdef __ANDROID__
+ // SDL2 2.28.5's AAudio backend (Android's default since API 26) only
+ // ever allows one open non-capture (playback) device at a time - it
+ // keeps a single static handle and asserts on a second open
+ // ('SDL_assert((audioDevice == NULL) || iscapture)' in
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
+ // it instead of AAudio.
+ SDL_SetHint(SDL_HINT_AUDIODRIVER, "openslES");
+#endif
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
DEBUG("%s: Init failed", __FUNCTION__);
}
@@ -0,0 +1,27 @@
--- a/src/Libraries/INPUT/Source/sdl_events.c
+++ b/src/Libraries/INPUT/Source/sdl_events.c
@@ -737,7 +737,24 @@
break;
case SDL_WINDOWEVENT_MOVED:
+ break;
+
case SDL_WINDOWEVENT_RESIZED:
+#ifdef __ANDROID__
+ // Android's SDL video backend (Android_SendResize() in
+ // src/video/android/SDL_androidvideo.c) only ever sends
+ // RESIZED for surface-driven resizes - e.g. the Quest Home
+ // shell settling the 2D panel into its requested <layout>
+ // defaultWidth/defaultHeight after activity launch - never
+ // SIZE_CHANGED (unlike desktop platforms, where an
+ // app-driven SDL_SetWindowSize() triggers both, handled
+ // above). Without this, opengl_resize() never re-runs after
+ // that late resize, leaving the GL viewport stuck at
+ // whatever (smaller) size the window first reported,
+ // rendered into one corner of the now-larger surface.
+ if (can_use_opengl())
+ opengl_resize(ev.window.data1, ev.window.data2);
+#endif
break;
case SDL_WINDOWEVENT_FOCUS_GAINED:
@@ -0,0 +1,24 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -332,6 +332,21 @@
int width, height;
SDL_GetWindowSize(window, &width, &height);
+#ifdef __ANDROID__
+ // Temporary diagnostic: the reported panel/surface size is correct
+ // (confirmed via SDLSurface's own "Window size" log and a Java-side
+ // onSizeChanged() probe showing no later relayout), but the rendered
+ // content still only fills a small corner of it. That means the
+ // divergence is somewhere below the Java/Activity layer - between what
+ // SDL_GetWindowSize() (the value used for opengl_resize() below) reports
+ // and what SDL/EGL/gl4es actually believe the live drawable size is.
+ // Compare all three directly instead of guessing further.
+ int drawable_w, drawable_h, output_w, output_h;
+ SDL_GL_GetDrawableSize(window, &drawable_w, &drawable_h);
+ SDL_GetRendererOutputSize(renderer, &output_w, &output_h);
+ INFO("Android size diag: SDL_GetWindowSize=%dx%d SDL_GL_GetDrawableSize=%dx%d SDL_GetRendererOutputSize=%dx%d",
+ width, height, drawable_w, drawable_h, output_w, output_h);
+#endif
opengl_resize(width, height);
// Now make the palettes
@@ -0,0 +1,10 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -440,6 +440,7 @@
${FLUIDSYNTH_LIBRARIES}
${OPENGL_LIBRARIES}
${ALSA_LIBRARIES}
+ $<$<BOOL:${ANDROID}>:log>
)
# Turn on address sanitizing if wanted (desktop only - not meaningful for
@@ -0,0 +1,45 @@
--- a/src/Libraries/LG/Source/LOG/src/log.c
+++ b/src/Libraries/LG/Source/LOG/src/log.c
@@ -28,6 +28,10 @@
#include "log.h"
+#ifdef __ANDROID__
+#include <android/log.h>
+#endif
+
static struct {
void *udata;
log_LockFn lock;
@@ -99,6 +103,23 @@
time_t t = time(NULL);
struct tm *lt = localtime(&t);
+#ifdef __ANDROID__
+ /* Plain stdout/stderr isn't captured by logcat on this platform (unlike
+ e.g. gl4es's own "LIBGL"-tagged logging, which goes through this same
+ API directly) - route there instead so every existing INFO/DEBUG/WARN/
+ ERROR call site in the engine becomes visible for on-device debugging,
+ with no changes needed at those call sites. */
+ if (!L.quiet) {
+ static const int android_priority[] = {
+ ANDROID_LOG_VERBOSE, ANDROID_LOG_DEBUG, ANDROID_LOG_INFO,
+ ANDROID_LOG_WARN, ANDROID_LOG_ERROR, ANDROID_LOG_FATAL
+ };
+ va_list args;
+ va_start(args, fmt);
+ __android_log_vprint(android_priority[level], "QuestShock", fmt, args);
+ va_end(args);
+ }
+#else
/* Log to stderr */
if (!L.quiet) {
va_list args;
@@ -116,6 +137,7 @@
va_end(args);
fprintf(stderr, "\n");
}
+#endif
/* Log to file */
if (L.fp) {
@@ -0,0 +1,31 @@
--- a/src/MacSrc/ShockBitmap.c
+++ b/src/MacSrc/ShockBitmap.c
@@ -42,11 +42,28 @@
SDL_RenderClear(renderer);
+#ifndef __ANDROID__
+ // On Android there's exactly one OS-controlled-size surface - no
+ // desktop-style window to resize, move, or toggle fullscreen on.
+ // SDL_SetWindowSize() still "succeeds" there (Android has no
+ // SetWindowSize driver hook, so SDL's generic layer just overwrites its
+ // own cached window->w/h to the requested game resolution, e.g.
+ // 640x480, and synthesizes a resize event from that) - which desyncs
+ // SDL's own notion of the window size from the real, unchanged Android
+ // surface size (e.g. 1600x1200), and that desync is exactly what then
+ // makes both SDL's internal renderer viewport and this engine's own
+ // custom GL viewport (OpenGL.cc's opengl_resize(), driven by that same
+ // now-wrong cached size) shrink to the game's resolution instead of
+ // filling the real surface - confirmed on-device via added logcat
+ // diagnostics (see android/engine-patches/07-09) showing the correct
+ // 1600x1200 size at startup, then this exact call sequence collapsing
+ // it to 640x480 the moment the splash screen sets its video mode.
extern bool fullscreenActive;
SDL_SetWindowFullscreen(window, fullscreenActive ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0);
SDL_SetWindowSize(window, width, height);
SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
+#endif
SDL_RenderSetLogicalSize(renderer, width, height);
@@ -0,0 +1,39 @@
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -64,6 +64,19 @@
endif(WIN32)
endif(ENABLE_OPENGL)
+# OpenXR loader (Android only for now - see android/app/src/main/cpp/
+# xr_session.c, which submits the game's existing render as a composition
+# layer instead of presenting to a normal window/surface). EGL is linked
+# explicitly too - xr_session.c calls eglGetCurrentDisplay/Context/
+# QueryContext/ChooseConfig directly (to share the GL context gl4es/SDL
+# already made), and unlike GL itself (where gl4es's own libGL.so already
+# provides every symbol the engine or xr_session.c call), there's no other
+# EGL provider in this link.
+if(ANDROID)
+ set(OPENXR_INCLUDE_DIRS ${ANDROID_PREBUILT_DIR}/openxr/include)
+ set(OPENXR_LIBRARIES ${ANDROID_PREBUILT_DIR}/openxr/lib/libopenxr_loader.so EGL)
+endif(ANDROID)
+
if(ENABLE_SDL2 MATCHES "ON")
find_package(SDL2 REQUIRED)
if(SDL2_FOUND)
@@ -109,6 +122,8 @@
${SDL2_MIXER_INCLUDE_DIRS}
${FLUIDSYNTH_INCLUDE_DIRS}
${OPENGL_INCLUDE_DIRS}
+ ${OPENXR_INCLUDE_DIRS}
+ ${ANDROID_EXTRA_INCLUDE_DIRS}
)
if(NOT WIN32)
@@ -439,6 +454,7 @@
${SDL2_MIXER_LIBRARIES}
${FLUIDSYNTH_LIBRARIES}
${OPENGL_LIBRARIES}
+ ${OPENXR_LIBRARIES}
${ALSA_LIBRARIES}
$<$<BOOL:${ANDROID}>:log>
)
@@ -0,0 +1,252 @@
--- a/src/MacSrc/OpenGL.cc 2026-07-25 07:10:06.189412183 +0200
+++ b/src/MacSrc/OpenGL.cc 2026-07-25 07:12:05.356301052 +0200
@@ -36,6 +36,7 @@
if (loc >= 0)
glUniform1f(loc, size);
}
+#include "xr_session.h"
#endif // __ANDROID__
extern "C" {
@@ -382,6 +383,21 @@
#endif
opengl_resize(width, height);
+#ifdef __ANDROID__
+ // Bring up the OpenXR session now that the shared GL context above is
+ // current (xr_init() reuses it via eglGetCurrentContext/Display - see
+ // xr_session.c) - sized to the game's own logical resolution, not the
+ // physical window, since the swapchain is composited by the XR runtime
+ // rather than upscaled by us into a physical-window-sized viewport (see
+ // opengl_swap_and_restore()/android_composite_software_frame() below).
+ // Failure here (e.g. no OpenXR runtime installed) just means the
+ // immersive path never activates - SDLDraw() falls back to the plain
+ // window-present path for the rest of the run.
+ int xr_logical_width, xr_logical_height;
+ SDL_RenderGetLogicalSize(renderer, &xr_logical_width, &xr_logical_height);
+ xr_init(xr_logical_width, xr_logical_height);
+#endif
+
// Now make the palettes
opaquePalette = SDL_AllocPalette(256);
transparentPalette = SDL_AllocPalette(256);
@@ -498,7 +514,109 @@
*y_scale = output_height / screen_height;
}
+#ifdef __ANDROID__
+// Shared by opengl_swap_and_restore()'s UI-overlay blit and
+// android_composite_software_frame() below - draws an SDL_Surface as a
+// fullscreen textured quad via gl4es. SDL's own renderer can't be used
+// here: it's tied to the window's own EGL surface, not whatever XR
+// swapchain framebuffer the caller already bound via xr_frame_begin().
+static void android_draw_surface_as_quad(SDL_Surface *ui, bool blend) {
+ SDL_Surface *uiRgba = SDL_ConvertSurfaceFormat(ui, SDL_PIXELFORMAT_RGBA32, 0);
+ if (uiRgba == nullptr)
+ return;
+
+ glUseProgram(textureShaderProgram.shaderProgram);
+ GLint tcAttrib = textureShaderProgram.tcAttrib;
+ GLint lightAttrib = textureShaderProgram.lightAttrib;
+ glUniform1i(textureShaderProgram.uniNightSight, false);
+ glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, IdentityMatrix);
+ glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, IdentityMatrix);
+
+ // Persistent texture, reused every call instead of a fresh
+ // gen/upload/delete each frame.
+ static GLuint s_uiTexture = 0;
+ if (s_uiTexture == 0)
+ glGenTextures(1, &s_uiTexture);
+ bind_texture(s_uiTexture);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ uiRgba->pixels);
+
+ set_blend_mode(blend);
+
+ // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
+ // immediate mode: glVertexAttrib*() is GLES2's *constant*-attribute
+ // API - without an enabled vertex array it sets one value for the
+ // whole draw call, not a fresh value per vertex. gl4es's immediate-mode
+ // emulation only reproduces desktop GL's per-vertex-capture behavior
+ // for attribute 0 (position, driven directly by glVertex3f), not for
+ // custom attributes like this shader's texcoords/light.
+ //
+ // V is flipped (1 at the bottom, 0 at the top): GL texture v=0
+ // addresses the first row of data passed to glTexImage2D, but uiRgba
+ // (an SDL surface) stores its rows top-down.
+ struct QuadVertex {
+ float x, y, z;
+ float u, v;
+ float light;
+ };
+ static const QuadVertex quadVerts[4] = {
+ {1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f},
+ {1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
+ {-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f},
+ {-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f},
+ };
+ glEnableVertexAttribArray(0);
+ glEnableVertexAttribArray(tcAttrib);
+ glEnableVertexAttribArray(lightAttrib);
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].x);
+ glVertexAttribPointer(tcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].u);
+ glVertexAttribPointer(lightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(QuadVertex),
+ &quadVerts[0].light);
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+ // The engine's other rendering uses glBegin/glVertexAttrib/glVertex3f
+ // immediate mode for attribute 0, which depends on it not having an
+ // enabled array bound.
+ glDisableVertexAttribArray(0);
+ glDisableVertexAttribArray(tcAttrib);
+ glDisableVertexAttribArray(lightAttrib);
+ glFlush();
+
+ SDL_FreeSurface(uiRgba);
+}
+
+void android_composite_software_frame(SDL_Surface *ui) {
+ // Deliberately no SDL_GL_MakeCurrent() here - see the comment in
+ // opengl_swap_and_restore() below for why.
+ glClear(GL_COLOR_BUFFER_BIT);
+ android_draw_surface_as_quad(ui, false);
+}
+#endif
+
void opengl_swap_and_restore(SDL_Surface *ui) {
+#ifdef __ANDROID__
+ // No SDL_GL_MakeCurrent() here on Android, deliberately - unlike the
+ // desktop path below. init_opengl() already made (window, context)
+ // current once; re-asserting it every frame turned out to reset
+ // gl4es's own internal "current framebuffer" bookkeeping back to
+ // whatever it associates with that context/surface pair (observed
+ // on-device: the XR swapchain image reliably received our real
+ // glClear/bind from xr_frame_begin(), but every actual gl4es-routed
+ // draw call after a fresh SDL_GL_MakeCurrent() here kept landing
+ // somewhere else - the quad stayed permanently black). Confirmed this
+ // exact class of gl4es/OpenXR interaction against RTCWQuest (Team
+ // Beef Studios), a shipped gl4es+Khronos-OpenXR-loader Quest port:
+ // it makes its EGL context current exactly once at init and never
+ // again per-frame, for the same reason.
+ //
+ // xr_frame_begin() (called from SDLDraw()) already bound the XR
+ // swapchain's framebuffer and a full-size viewport - no physical-
+ // window letterboxing needed here either, unlike the desktop/2D-panel
+ // path below, since the swapchain is sized to exactly the game's
+ // logical resolution (see xr_init()).
+ glClear(GL_COLOR_BUFFER_BIT);
+#else
// restore the view backup (without HUD overlay) for incremental
// updates in the subsequent frame
SDL_GL_MakeCurrent(window, context);
@@ -510,6 +628,7 @@
// Set the drawable area for the 3d view
glViewport(phys_offset_x * x_hdpi_scale, phys_offset_y * y_hdpi_scale, phys_width * x_hdpi_scale,
phys_height * y_hdpi_scale);
+#endif
set_blend_mode(false);
// Bind and setup our general shader program
@@ -552,6 +671,11 @@
if (err != GL_NO_ERROR)
ERROR("OpenGL error: %i", err);
+#ifdef __ANDROID__
+ // Blit the UI canvas over the 3d view (see android_draw_surface_as_quad()
+ // above - SDL's renderer can't target the XR swapchain framebuffer).
+ android_draw_surface_as_quad(ui, true);
+#else
// Blit the UI canvas over the 3d view
SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, ui);
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
@@ -561,6 +685,7 @@
// Finally, swap to the screen
SDL_RenderPresent(renderer);
+#endif
}
void toggle_opengl() {
--- a/src/MacSrc/OpenGL.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/OpenGL.h 2026-07-25 07:10:06.353410581 +0200
@@ -22,6 +22,15 @@
void opengl_swap_and_restore(SDL_Surface *ui);
void opengl_change_palette();
+#ifdef __ANDROID__
+// Composites a plain software-rendered frame (the same content SDLDraw's
+// non-OpenGL fallback would otherwise present via SDL_RenderCopy) into the
+// XR swapchain image already bound by xr_frame_begin() - see Shock.c's
+// SDLDraw(), which needs this for the splash screen/cutscenes/menus that
+// render this way instead of through opengl_swap_and_restore().
+void android_composite_software_frame(SDL_Surface *ui);
+#endif
+
void opengl_set_viewport(int x, int y, int width, int height);
int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
int opengl_light_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
--- a/src/MacSrc/Shock.c 2026-07-25 07:10:06.181412261 +0200
+++ b/src/MacSrc/Shock.c 2026-07-25 07:10:06.353410581 +0200
@@ -30,6 +30,10 @@
#include <math.h>
#include <SDL.h>
+#ifdef __ANDROID__
+#include "xr_session.h"
+#endif
+
#include "InitMac.h"
#include "Modding.h"
#include "OpenGL.h"
@@ -301,6 +305,21 @@
}
void SDLDraw() {
+#ifdef __ANDROID__
+ // The true once-a-frame present hook for the immersive OpenXR path -
+ // unlike opengl_swap_and_restore() below, this runs every frame
+ // regardless of should_opengl_swap() (e.g. also during the splash
+ // screen/cutscenes/menus, which render through the plain software
+ // path below, not the OpenGL one). xr_poll_events() needs exactly that
+ // - called unconditionally - to ever observe the session reach a
+ // running state in the first place.
+ xr_poll_events();
+ if (!xr_frame_begin()) {
+ xr_frame_end();
+ return;
+ }
+#endif
+
if (should_opengl_swap()) {
// We want the UI background to be transparent!
sdlPalette->colors[255].a = 0x00;
@@ -310,14 +329,27 @@
// Set the palette back, and we are done
sdlPalette->colors[255].a = 0xff;
+#ifdef __ANDROID__
+ xr_frame_end();
+#endif
return;
}
+#ifdef __ANDROID__
+ // Same software-rendered frame the desktop path below would present
+ // via SDL_RenderCopy/SDL_RenderPresent, composited into the XR
+ // swapchain image xr_frame_begin() bound above instead - SDL's
+ // renderer is tied to the window's own EGL surface, not that FBO.
+ android_composite_software_frame(drawSurface);
+ xr_frame_end();
+ return;
+#endif
+
// Clear the screen!
SDL_RenderClear(renderer);
SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, drawSurface);
- // Blit to the screen by drawing the surface
+ // Blit to the screen by drawing the surface
SDL_Rect srcRect = {0, 0, gScreenWide, gScreenHigh};
SDL_RenderCopy(renderer, texture, &srcRect, NULL);
SDL_DestroyTexture(texture);
+57 -3
View File
@@ -46,10 +46,25 @@ ARG ANDROID_COMPILE_SDK_VERSION=34
ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0
ARG ANDROID_NDK_VERSION=26.1.10909125 ARG ANDROID_NDK_VERSION=26.1.10909125
ARG ANDROID_CMAKE_VERSION=3.22.1 ARG ANDROID_CMAKE_VERSION=3.22.1
# NDK 26 doesn't yet default to 16 KB-aligned ELF LOAD segments (that only
# became automatic in NDK 28+) - Google requires this for Play Store
# submissions targeting Android 15+ as of Nov 2025, and it's cheap/harmless
# to do regardless of Play Store status. Passed to every Android shared-lib
# CMake configure below, and to the engine's own build via
# android/app/build.gradle's externalNativeBuild.cmake.arguments.
ARG ANDROID_16KB_LDFLAGS="-Wl,-z,max-page-size=16384"
# GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's # GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's
# desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so # desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so
# engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android. # engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android.
ARG ANDROID_GL4ES_VERSION=1.1.6 ARG ANDROID_GL4ES_VERSION=1.1.6
# Khronos' own OpenXR loader (https://github.com/KhronosGroup/OpenXR-SDK) -
# the vendor-neutral loader, not Meta's redistributed copy, per this
# project's "favor OpenXR" rule (see README's Design principles). Only the
# loader is built (BUILD_LOADER=ON, everything else off) - the actual XR
# runtime implementation is resolved on-device by Horizon OS at load time,
# nothing else needs bundling. Verify this is still the latest release tag
# at https://github.com/KhronosGroup/OpenXR-SDK/releases when bumping.
ARG ANDROID_OPENXR_VERSION=1.1.42
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
@@ -77,12 +92,17 @@ ENV DEBIAN_FRONTEND=noninteractive
# build cache for this layer - and vice versa, adding a package here only # build cache for this layer - and vice versa, adding a package here only
# ever costs a rebuild of the (slow) layers below it, not a re-download # ever costs a rebuild of the (slow) layers below it, not a re-download
# of packages that didn't change. # of packages that didn't change.
# patchelf: fixes up GL4ES's built-in ELF SONAME below (see the gl4es
# build step) - the Android APK packager (AGP) refuses to bundle a
# jniLibs file whose name doesn't literally end in ".so", but the
# dynamic linker resolves NEEDED entries by embedded SONAME, not
# filename - patchelf lets both agree on "libGL.so".
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake make git curl ca-certificates pkg-config gosu \ build-essential cmake make git curl ca-certificates pkg-config gosu \
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \ libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \ libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
libogg-dev libvorbis-dev libasound2-dev openssh-client \ libogg-dev libvorbis-dev libasound2-dev openssh-client \
openjdk-17-jdk-headless unzip \ openjdk-17-jdk-headless unzip patchelf \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /opt/prebuilt WORKDIR /opt/prebuilt
@@ -177,6 +197,7 @@ RUN curl -sSLO "https://www.libsdl.org/release/SDL2-${ANDROID_SDL2_VERSION}.tar.
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \ -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \
-DSDL_STATIC=OFF \ -DSDL_STATIC=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-sdl2-android -j"$(nproc)" \ && cmake --build build-sdl2-android -j"$(nproc)" \
&& cmake --install build-sdl2-android \ && cmake --install build-sdl2-android \
&& rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android && rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android
@@ -202,6 +223,7 @@ RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-${A
-DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \ -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \
-DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \ -DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \
-DSDL2MIXER_WAVPACK=OFF \ -DSDL2MIXER_WAVPACK=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-sdl2mixer-android -j"$(nproc)" \ && cmake --build build-sdl2mixer-android -j"$(nproc)" \
&& cmake --install build-sdl2mixer-android \ && cmake --install build-sdl2mixer-android \
&& rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android && rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android
@@ -217,6 +239,7 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-
&& cmake -S fluidsynth-lite-android -B build-fluidsynth-android \ && cmake -S fluidsynth-lite-android -B build-fluidsynth-android \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-fluidsynth-android -j"$(nproc)" \ && cmake --build build-fluidsynth-android -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \ && mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \
&& cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \ && cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \
@@ -231,8 +254,17 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-
# gl4es's own CMakeLists.txt writes its output straight to # gl4es's own CMakeLists.txt writes its output straight to
# ${CMAKE_SOURCE_DIR}/lib (the source tree, not the build dir) and gives # ${CMAKE_SOURCE_DIR}/lib (the source tree, not the build dir) and gives
# the GL target a ".so.1" suffix - stage explicitly rather than # the GL target a ".so.1" suffix, both in filename and in its embedded
# `cmake --install` (which it doesn't support for this target anyway). # ELF SONAME - stage explicitly rather than `cmake --install` (which it
# doesn't support for this target anyway). Renaming the *file* to
# "libGL.so" isn't enough on its own: the dynamic linker resolves
# libmain.so's NEEDED entry by the *embedded* SONAME, not by whatever
# filename it was linked against, so a bare rename would link fine but
# fail to dlopen on-device. And keeping the real "libGL.so.1" filename
# isn't an option either - AGP's jniLibs packaging silently drops any
# native library whose filename doesn't literally end in ".so". So
# patchelf the embedded SONAME to "libGL.so" too, making the rename
# consistent both at link time and at runtime.
RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \ RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \
https://github.com/ptitSeb/gl4es.git gl4es-android \ https://github.com/ptitSeb/gl4es.git gl4es-android \
&& rm -rf gl4es-android/.git \ && rm -rf gl4es-android/.git \
@@ -240,12 +272,34 @@ RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \ -DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-gl4es-android -j"$(nproc)" \ && cmake --build build-gl4es-android -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \ && mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \
&& cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \ && cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \
&& patchelf --set-soname libGL.so /opt/prebuilt/android/gl4es/lib/libGL.so \
&& cp -a gl4es-android/include/. /opt/prebuilt/android/gl4es/include/ \ && cp -a gl4es-android/include/. /opt/prebuilt/android/gl4es/include/ \
&& rm -rf gl4es-android build-gl4es-android && rm -rf gl4es-android build-gl4es-android
# OpenXR loader, for the immersive VR mode (android/app/src/main/cpp/
# xr_session.c and friends) - see android/engine-patches/
# 11-android-openxr-cmake.patch for how the engine links it.
RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \
https://github.com/KhronosGroup/OpenXR-SDK.git openxr-sdk-android \
&& rm -rf openxr-sdk-android/.git \
&& cmake -S openxr-sdk-android -B build-openxr-android \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/openxr \
-DBUILD_LOADER=ON -DBUILD_TESTS=OFF -DBUILD_API_LAYERS=OFF \
-DBUILD_CONFORMANCE_TESTS=OFF -DBUILD_WITH_SYSTEM_JSONCPP=OFF \
-DDYNAMIC_LOADER=ON \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-openxr-android -j"$(nproc)" --target openxr_loader \
&& cmake --install build-openxr-android \
&& mkdir -p /opt/prebuilt/android/openxr/include \
&& cp -a openxr-sdk-android/include/. /opt/prebuilt/android/openxr/include/ \
&& rm -rf openxr-sdk-android build-openxr-android
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
+19 -43
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds the Quest APK from a scratch, patched copy of engine/ (see # Builds the Quest APK - see build-image/prepare-android-project.sh (which
# android/engine-patches/ - engine/ itself is never modified) plus the # this calls) for how android/ gets a scratch, patched copy of engine/,
# Android SDL2/SDL2_mixer/fluidsynth-lite prebuilt into this image at # the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts, and bundled
# /opt/prebuilt/android/*. Run via ../run-image.sh (or directly, if # assets staged in. Run via ../run-image.sh (or directly, if already
# already inside this image - see the Makefile's `apk` target). # inside this image - see the Makefile's `apk` target).
# #
# Unlike build-engine.sh, this step needs network access: Gradle/AGP's # Unlike build-engine.sh, this step needs network access: Gradle/AGP's
# own dependency resolution isn't prebuilt into the image (see # own dependency resolution isn't prebuilt into the image (see
@@ -12,53 +12,29 @@ set -euo pipefail
REPO_ROOT="$(pwd)" REPO_ROOT="$(pwd)"
ANDROID_DIR="$REPO_ROOT/android" ANDROID_DIR="$REPO_ROOT/android"
SCRATCH_ENGINE="$REPO_ROOT/build/android-engine"
echo "== Preparing a scratch, patched copy of engine/ ==" bash "$REPO_ROOT/build-image/prepare-android-project.sh"
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
for p in "$ANDROID_DIR"/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
echo "== Wiring up prebuilt Android SDL2_mixer/fluidsynth-lite (BUNDLED mode, like the desktop build) ==" echo "== Determining version (shared with 'make package', see build-image/version.sh) =="
mkdir -p "$SCRATCH_ENGINE/build_ext/built_sdl_mixer" "$SCRATCH_ENGINE/build_ext/fluidsynth-lite" git config --global --add safe.directory "$REPO_ROOT" 2>/dev/null || true
cp -a /opt/prebuilt/android/sdl2_mixer/. "$SCRATCH_ENGINE/build_ext/built_sdl_mixer/" VERSION="$("$REPO_ROOT/build-image/version.sh")"
mkdir -p "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src" # versionName (VERSION, above) can be anything, but Android's versionCode
cp -a /opt/prebuilt/android/fluidsynth-lite/lib/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src/" # must be a positive, monotonically-increasing integer - the commit count
cp -a /opt/prebuilt/android/fluidsynth-lite/include/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/include/" # is a simple, deterministic stand-in for that.
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
echo "engineDir=$SCRATCH_ENGINE" > "$ANDROID_DIR/engine.properties" echo "Version: $VERSION (versionCode $VERSION_CODE)"
echo "== Staging bundled assets (shaders, soundfont, get-assets text) =="
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
rm -rf "$ASSETS_DIR"
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
# GLES ports of engine/shaders/ (see android/gles-shaders/ and
# android/engine-patches/04-android-opengl-es-render.patch) - not
# engine/shaders/ itself, which is desktop-only GLSL.
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es .so's into jniLibs =="
JNI_LIBS_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a"
rm -rf "$ANDROID_DIR/app/src/main/jniLibs"
mkdir -p "$JNI_LIBS_DIR"
find /opt/prebuilt/android/sdl2/lib /opt/prebuilt/android/sdl2_mixer/lib /opt/prebuilt/android/fluidsynth-lite/lib \
/opt/prebuilt/android/gl4es/lib \
-name '*.so' -exec cp -a {} "$JNI_LIBS_DIR/" \;
echo "== Building the APK (gradlew assembleDebug) ==" echo "== Building the APK (gradlew assembleDebug) =="
cd "$ANDROID_DIR" cd "$ANDROID_DIR"
./gradlew --no-daemon assembleDebug ./gradlew --no-daemon assembleDebug \
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; } [ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
mkdir -p "$REPO_ROOT/dist" mkdir -p "$REPO_ROOT/dist"
cp "$APK" "$REPO_ROOT/dist/questshock-debug.apk" PKG_NAME="questshock-$VERSION-android-arm64.apk"
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
echo "== Done ==" echo "== Done =="
echo "APK: $REPO_ROOT/dist/questshock-debug.apk" echo "APK: $REPO_ROOT/dist/$PKG_NAME"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Prepares android/ to be built: a scratch, patched copy of engine/ (see
# android/engine-patches/ - engine/ itself is never modified), the
# Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/openxr prebuilts staged
# into jniLibs, bundled assets, and android/engine.properties (engineDir/
# prebuiltDir, read by android/app/build.gradle). Always run inside this
# image (via ../run-image.sh, or directly if already inside it) - it
# reads from /opt/prebuilt/android/*, which only exists there.
#
# Two modes:
# (no args) - for build-apk.sh's own gradlew, invoked in this same
# container - engine.properties points at this
# container's own paths (e.g. /workspace/build/...).
# --host-paths - for building android/ natively in Android Studio on
# the HOST afterwards, instead of via build-apk.sh.
# Needs HOST_REPO_ROOT (set by run-image.sh) to know
# this container's bind-mounted repo root's path on
# the host, since a plain container-internal path
# like /workspace/... doesn't resolve there. Also
# exports /opt/prebuilt/android (host-invisible,
# image-only) to build/android-prebuilt so a host-side
# CMake configure can actually find it.
set -euo pipefail
HOST_PATHS=0
if [ "${1:-}" = "--host-paths" ]; then
HOST_PATHS=1
fi
REPO_ROOT="$(pwd)"
ANDROID_DIR="$REPO_ROOT/android"
SCRATCH_ENGINE="$REPO_ROOT/build/android-engine"
PREBUILT_EXPORT_DIR="$REPO_ROOT/build/android-prebuilt"
echo "== Preparing a scratch, patched copy of engine/ =="
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
for p in "$ANDROID_DIR"/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
echo "== Wiring up prebuilt Android SDL2_mixer/fluidsynth-lite (BUNDLED mode, like the desktop build) =="
mkdir -p "$SCRATCH_ENGINE/build_ext/built_sdl_mixer" "$SCRATCH_ENGINE/build_ext/fluidsynth-lite"
cp -a /opt/prebuilt/android/sdl2_mixer/. "$SCRATCH_ENGINE/build_ext/built_sdl_mixer/"
mkdir -p "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src"
cp -a /opt/prebuilt/android/fluidsynth-lite/lib/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src/"
cp -a /opt/prebuilt/android/fluidsynth-lite/include/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/include/"
if [ "$HOST_PATHS" -eq 1 ]; then
: "${HOST_REPO_ROOT:?--host-paths needs HOST_REPO_ROOT set - run via ./run-image.sh, not directly}"
echo "== Exporting prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es for host-side use =="
rm -rf "$PREBUILT_EXPORT_DIR"
cp -a /opt/prebuilt/android "$PREBUILT_EXPORT_DIR"
ENGINE_DIR_PROP="$HOST_REPO_ROOT/build/android-engine"
PREBUILT_DIR_PROP="$HOST_REPO_ROOT/build/android-prebuilt"
else
ENGINE_DIR_PROP="$SCRATCH_ENGINE"
PREBUILT_DIR_PROP="/opt/prebuilt/android"
fi
{
echo "engineDir=$ENGINE_DIR_PROP"
echo "prebuiltDir=$PREBUILT_DIR_PROP"
} > "$ANDROID_DIR/engine.properties"
echo "== Staging bundled assets (shaders, soundfont, get-assets text) =="
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
rm -rf "$ASSETS_DIR"
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
# GLES ports of engine/shaders/ (see android/gles-shaders/ and
# android/engine-patches/04-android-opengl-es-render.patch) - not
# engine/shaders/ itself, which is desktop-only GLSL.
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/openxr .so's into jniLibs =="
JNI_LIBS_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a"
rm -rf "$ANDROID_DIR/app/src/main/jniLibs"
mkdir -p "$JNI_LIBS_DIR"
find /opt/prebuilt/android/sdl2/lib /opt/prebuilt/android/sdl2_mixer/lib /opt/prebuilt/android/fluidsynth-lite/lib \
/opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/openxr/lib \
-name '*.so' -exec cp -a {} "$JNI_LIBS_DIR/" \;
echo "== Done =="
echo "engineDir=$ENGINE_DIR_PROP"
echo "prebuiltDir=$PREBUILT_DIR_PROP"
if [ "$HOST_PATHS" -eq 1 ]; then
echo "android/ is ready to open and build natively in Android Studio on the host."
fi
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Prints the release version used to name both dist/shockolate-*.tar.gz
# (see `make package`) and dist/questshock-*.apk (see build-apk.sh), so
# the two are always versioned identically.
#
# Defaults to the current git tag (vX.Y.Z, prefix stripped) if HEAD is
# exactly on one matching that pattern; otherwise a 0.0.0-dev+<sha>
# placeholder (with a warning on stderr) - push a vX.Y.Z tag to drive a
# real release version. Override either way with VERSION=1.2.3 in the
# environment.
set -euo pipefail
V="${VERSION:-}"
if [ -z "$V" ]; then
if TAG=$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then
V="${TAG#v}"
else
V="0.0.0-dev+$(git rev-parse --short HEAD)"
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $V (push a tag to drive a real release version)" >&2
fi
fi
case "$V" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "PREFLIGHT FAIL: VERSION '$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;;
esac
echo "$V"
+8 -10
View File
@@ -1,16 +1,14 @@
This package does not include System Shock's game data - it's This package does not include System Shock's game data - it's
copyrighted, proprietary content that can't be redistributed. To play, copyrighted, proprietary content that can't be redistributed. To play,
you need a copy of System Shock: Enhanced Edition (e.g. from gog.com). you need to own a copy of System Shock: Enhanced Edition (e.g. from
gog.com), installed (on Windows, or via Wine/Proton on Linux).
From your Enhanced Edition install, you need its classic-game data and Copy its res/data/ and res/sound/ folders directly into place here,
sound files - the res/pc/hd/data and res/pc/cdrom/data trees merged alongside this file:
together (res/pc/hd's copies win the couple of filenames present in res/data/
both: intro.res, objprop.dat), and the res/pc/hd/sound tree, packed res/sound/
inside the install's sshock.kpf (a zip file). They already contain everything needed, in the right layout - no
merging or extraction required.
Copy that merged data into place, alongside this file:
res/data/ <- res/pc/hd/data + res/pc/cdrom/data, merged
res/sound/ <- res/pc/hd/sound
Once res/data/ and res/sound/ exist next to this file, run ./run.sh from Once res/data/ and res/sound/ exist next to this file, run ./run.sh from
the root of this package to play. the root of this package to play.
+6 -9
View File
@@ -1,17 +1,14 @@
This app does not include System Shock's game data - it's copyrighted, This app does not include System Shock's game data - it's copyrighted,
proprietary content that can't be redistributed. To play, you need a proprietary content that can't be redistributed. To play, you need to
copy of System Shock: Enhanced Edition (e.g. from gog.com). own a copy of System Shock: Enhanced Edition (e.g. from gog.com),
installed (on Windows, or via Wine/Proton on Linux).
From your Enhanced Edition install, you need its classic-game data and
sound files - the res/pc/hd/data and res/pc/cdrom/data trees merged
together (res/pc/hd's copies win the couple of filenames present in
both: intro.res, objprop.dat), and the res/pc/hd/sound tree, packed
inside the install's sshock.kpf (a zip file).
Using SideQuest (or any MTP file browser) with your Quest connected, Using SideQuest (or any MTP file browser) with your Quest connected,
copy that merged data into: copy its res/data/ and res/sound/ folders directly into:
/sdcard/questshock/res/data/ /sdcard/questshock/res/data/
/sdcard/questshock/res/sound/ /sdcard/questshock/res/sound/
They already contain everything needed, in the right layout - no
merging or extraction required.
(shaders/ and this app's default soundfont are already here, extracted (shaders/ and this app's default soundfont are already here, extracted
automatically on first launch - only res/data/ and res/sound/ are automatically on first launch - only res/data/ and res/sound/ are
Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+6
View File
@@ -39,11 +39,17 @@ docker image inspect "$IMAGE" >/dev/null 2>&1 \
TTY_FLAGS="-i" TTY_FLAGS="-i"
[ -t 1 ] && TTY_FLAGS="-it" [ -t 1 ] && TTY_FLAGS="-it"
# HOST_REPO_ROOT: lets scripts running in the container (e.g.
# build-image/prepare-android-project.sh --host-paths) write paths that
# resolve on the HOST filesystem instead of this container's own
# /workspace - needed so a gradlew/Android Studio running natively on the
# host afterwards can find them.
# shellcheck disable=SC2086 # shellcheck disable=SC2086
docker run --rm $TTY_FLAGS \ docker run --rm $TTY_FLAGS \
-v "$ROOT:/workspace" \ -v "$ROOT:/workspace" \
-e HOST_UID="$(id -u)" \ -e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \ -e HOST_GID="$(id -g)" \
-e HOST_REPO_ROOT="$ROOT" \
"$IMAGE" \ "$IMAGE" \
"$@" "$@"