4 Commits

Author SHA1 Message Date
matthias a2fb95e57b Add Windows cross-compilation via MinGW, and fix Docker build permission
build / build (push) Successful in 3m5s
issues on vboxsf-mounted checkouts

New `make dist-win`/`package-win` targets (folded into `dist`/`package`
alongside the renamed `dist-linux`/`package-linux`) cross-compile
systemshock.exe via MinGW, using prebuilt SDL2/SDL2_mixer/GLEW/
fluidsynth-lite baked into the build-image - no Windows machine or Wine
needed to build it, confirmed working and playable on a real Windows
machine. dist-win/ ships DLLs flat alongside the exe plus a new
res/run.bat launcher, packaged into a .zip the same way dist/ becomes a
.tar.gz.

Also fixes three build-image bugs hit while testing on a VirtualBox
vboxsf-mounted checkout: build-engine.sh/docker-entrypoint.sh losing
their execute bit (chmod +x on restrictive source perms), the container
user missing access to /workspace's supplementary vboxsf group, and
cp -a failing on symlink/hard-link creation (vboxsf doesn't support
either) - now falls back to dereferencing copies when detected. Also
adds Docker/zip/etc. prerequisites to the README for both the desktop
and Quest builds.
2026-08-16 06:27:31 +02:00
ml 581af95e7b Drive the engine's mouse cursor from the VR aim ray on the game quad
build / build (push) Successful in 1m44s
The game quad's laser beam was purely visual - aiming at the game's own
UI (inventory, menus, dialogs) had no way to interact with it. Add a
real mouse pointer: absolute cursor position plus a single left click,
mirroring a plain point-and-click mouse (no mouselook, no right-click,
no drag).

New xr_mouse.c/h bridges the aim-ray hit-test to the vendored engine's
own public mouse API - mouse_put_xy() for position, a synthetic
SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) via SDL_PushEvent for clicks,
the same technique nativeSendPrintableChar() already uses for
synthetic keyboard input. No engine patch needed. Deliberately bypasses
SDLActivity.onNativeMouse()/SDL_MOUSEMOTION/SetMouseXY(), whose
physical-window-size scaling is unreliable in this headless immersive
build.

xr_input_try_game_quad() now reports the hit u,v; a new
g_game_touch_active[] tracks a held click on the game quad so it
survives the ray straying onto the keyboard/menu before release,
gating those overlays' claims the same way pending overlay touches
already do.
2026-08-14 08:36:09 +02:00
ml 824b4a2506 Give the game quad the same laser-beam pointer as the overlays
Aiming looked and behaved differently depending on whether the
keyboard/menu was open: xr_input_try_overlay() claimed a hand's ray for
its overlay whenever the overlay was merely visible, regardless of
whether the ray was actually pointed at it - so with the keyboard open,
aiming at the main game screen still showed a beam terminating at the
keyboard's fixed depth, while closing the keyboard swapped in the old
flat cross-shaped reticle at whatever the ray was really pointing at.

Only claim an overlay when the ray is actually relevant this frame - a
real hit, or a touch/drag begun on a previous frame still pending
release - so a visible-but-unaimed-at overlay now falls through to the
next-priority target. Give the game quad its own hit-test
(xr_input_try_game_quad()) and the same billboarded laser-beam
treatment as the overlays instead of the old flat reticle, so aiming
looks and behaves identically everywhere - overlay open or not. The
old reticle GL program/shaders are now fully dead and removed. No
click/touch dispatch is added to the game quad itself; that's future
work.
2026-08-14 07:59:09 +02:00
ml 920842a465 Show a laser-beam pointer while aiming at the menu/keyboard
build / build (push) Successful in 1m40s
The flat cross-shaped reticle used while aiming at the game quad is
drawn directly into the game quad's own texture, so it only ever made
sense there - while the menu launcher or keyboard was open, there was
no visual feedback at all until the ray was precisely on target,
making both panels hard to aim at.

Add a second, thin XrCompositionLayerQuad per hand: a billboarded
"laser beam" ribbon from the controller to wherever that hand's ray
currently crosses the plane of whichever overlay (keyboard, then menu)
claims it that frame, oriented via a view-space head pose so it reads
as a line from the viewer's eye regardless of angle, colored per hand
(cyan left, amber right) and clamped to 2m so grazing angles don't
produce an absurdly long beam. Built from data xr_input.c's existing
per-hand hit-testing already computes; the game-quad reticle itself is
untouched.
2026-08-14 07:30:13 +02:00
19 changed files with 990 additions and 207 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+19 -10
View File
@@ -1,14 +1,16 @@
name: build
# Builds a versioned Linux release tarball (see `make package`) on every
# push/PR, plus on-demand via the Gitea "Run workflow" button. Runs
# Builds versioned Linux and Windows release packages (see `make
# package`, which builds both - a .tar.gz and a .zip) on every push/PR,
# plus on-demand via the Gitea "Run workflow" button. Runs
# inside the build-image (see ../../build-image/Dockerfile, built/pushed
# via ../../build-image.sh and ../../upload-image.sh), which bundles
# every dependency engine/ needs to compile - no network access needed
# at job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine` prerequisite
# compiles directly instead of trying to docker-run it again, which
# wouldn't work here (no nested docker).
# every dependency engine/ needs to compile (for both platforms,
# including the Windows cross-toolchain) - no network access needed at
# job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine`/`engine-win`
# prerequisites compile directly instead of trying to docker-run it
# again, which wouldn't work here (no nested docker).
on:
push:
pull_request:
@@ -50,17 +52,22 @@ jobs:
# comment) - openxr was added to this image in the same
# Android/Quest layer, so it's an equally good marker of that.
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
[ -d /opt/prebuilt/win/sdl2 ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/win/sdl2 missing - runner is using a build-image older than the Windows cross-build layer" >&2; exit 1; }
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: x86_64-w64-mingw32-gcc not in PATH" >&2; exit 1; }
command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not in PATH (needed by make package-win)" >&2; exit 1; }
- name: Build package
run: make package
- name: Upload build artifact
- name: Upload build artifacts
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
uses: actions/upload-artifact@v3
with:
name: shockolate
path: dist/shockolate-*-linux-*.tar.gz
path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
- name: Build Quest APK
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
@@ -78,13 +85,14 @@ jobs:
path: dist/questshock-*-android-*.apk
- name: Publish to dl.ladkau.de
# Uploads the tarball and APK over SFTP instead of using
# Uploads the tarball, zip, and APK over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
ZIP="$(ls dist/shockolate-*-windows-*.zip)"
APK="$(ls dist/questshock-*-android-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
@@ -94,5 +102,6 @@ jobs:
uploader@dl.ladkau.de <<EOF
-mkdir files/questshock
put $TARBALL files/questshock/$(basename "$TARBALL")
put $ZIP files/questshock/$(basename "$ZIP")
put $APK files/questshock/$(basename "$APK")
EOF
+6 -1
View File
@@ -3,16 +3,21 @@
# Game assets are not included in the repo
/res/assets/setup_system_shock_enhanced*
/res/assets/*.zip
/res/assets/ss_ee/
# Build output
/dist/
/dist-win/
/build/
# Engine build artifacts (engine/ is committed as a source snapshot; these
# are generated by run-image.sh / build-image/build-engine.sh)
# are generated by run-image.sh / build-image/build-engine.sh /
# build-image/build-engine-win.sh - the Windows cross-build's own scratch
# copy of engine/ lives under /build/win-engine/, already covered above)
/engine/build_ext/
/engine/.build-output/
/engine/.build-output-win/
/engine/CMakeCache.txt
/engine/CMakeFiles/
/engine/cmake_install.cmake
+79 -10
View File
@@ -1,20 +1,28 @@
# Assembles dist/ - a self-contained, runnable copy of System Shock -
# from the compiled engine (built via Docker, see build-image.sh/
# run-image.sh) and the game assets extracted from a purchased copy (see
# Assembles dist/ (Linux) and dist-win/ (Windows, cross-compiled via
# MinGW) - self-contained, runnable copies of System Shock - from the
# compiled engine (built via Docker, see build-image.sh/run-image.sh) and
# the game assets extracted from a purchased copy (see
# res/assets/extract_assets.sh). Building the engine needs the build-image
# (./build-image.sh, once); everything else here is plain file copying.
# `dist-linux`/`dist-win` build just one platform; plain `dist` builds
# both.
#
# `make package` instead builds a redistributable tarball that omits the
# proprietary game assets entirely (see res/assets/GET_ASSETS.txt, which
# it ships in their place) - this is what CI publishes.
# `make package` instead builds redistributable archives (a .tar.gz for
# Linux, a .zip for Windows) that omit the proprietary game assets
# entirely (see res/assets/GET_ASSETS.txt, which they ship in their
# place) - this is what CI publishes. Same dist-linux/dist-win/(both)
# split via package-linux/package-win/package.
DIST_DIR := dist
DIST_WIN_DIR := dist-win
ENGINE_OUT := engine/.build-output
ENGINE_OUT_WIN := engine/.build-output-win
ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build
ARCH := $(shell uname -m)
.PHONY: all dist build-image engine assets package apk android-studio clean
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
package package-linux package-win apk android-studio clean
all: dist
@@ -40,6 +48,16 @@ engine:
./run-image.sh; \
fi
# Cross-compiles engine/ for Windows via MinGW - see build-engine-win.sh
# for why this can't share build-engine.sh's engine/build_ext/ (a scratch
# copy is used instead). Same QUESTSHOCK_BUILD_IMAGE detection as `engine`.
engine-win:
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
bash build-image/build-engine-win.sh; \
else \
./run-image.sh bash build-image/build-engine-win.sh; \
fi
# Fails with a pointer to extract_assets.sh if the purchased game assets
# haven't been extracted yet.
assets:
@@ -49,7 +67,10 @@ assets:
exit 1; \
fi
dist: engine assets
# Builds both platforms. `dist-linux`/`dist-win` build just one.
dist: dist-linux dist-win
dist-linux: engine assets
@echo "== Assembling $(DIST_DIR) =="
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
@@ -64,6 +85,27 @@ dist: engine assets
chmod +x "$(DIST_DIR)/run.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory
# first, so (unlike run.sh's LD_LIBRARY_PATH) run.bat needs no extra
# wiring for that.
dist-win: engine-win assets
@echo "== Assembling $(DIST_WIN_DIR) =="
rm -rf "$(DIST_WIN_DIR)"
mkdir -p "$(DIST_WIN_DIR)/res/data" "$(DIST_WIN_DIR)/res/sound"
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$(DIST_WIN_DIR)/"
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$(DIST_WIN_DIR)/res/"
cp -a engine/shaders "$(DIST_WIN_DIR)/shaders"
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/
# `package-win` build just one.
package: package-linux package-win
# Builds a versioned, redistributable Linux release tarball at
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
@@ -72,7 +114,7 @@ dist: engine assets
# tag to drive a release. Override with `make package VERSION=1.2.3`, or
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
# placeholder version, with a warning).
package: engine
package-linux: engine
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
@@ -95,6 +137,33 @@ package: engine
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
# Windows counterpart of package-linux: dist/shockolate-<version>-windows-
# x86_64.zip. Same versioning (build-image/version.sh) and VERSION=
# override. Needs `zip` on whatever host runs `make package-win` itself
# (unlike engine-win's own build, packaging isn't run inside the
# build-image) - see README's Desktop build prerequisites.
package-win: engine-win
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not found in PATH" >&2; exit 1; }
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-windows-x86_64"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE/res"; \
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$$PKG_STAGE/"; \
cp -a engine/shaders "$$PKG_STAGE/shaders"; \
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$$PKG_STAGE/res/"; \
cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt"; \
cp res/run.bat "$$PKG_STAGE/run.bat"; \
cp LICENSE "$$PKG_STAGE/LICENSE"; \
cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \
cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \
mkdir -p "$(DIST_DIR)"; \
(cd "$(BUILD_DIR)/package" && zip -rq "$(CURDIR)/$(DIST_DIR)/$$PKG_NAME.zip" "$$PKG_NAME"); \
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.zip"
# Builds the Quest APK (see build-image/build-apk.sh and
# android/engine-patches/ - engine/ itself is never modified; a patch is
# applied to a scratch copy at build time instead). Same
@@ -124,7 +193,7 @@ android-studio:
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
clean:
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \
rm -rf "$(DIST_DIR)" "$(DIST_WIN_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" "$(ENGINE_OUT_WIN)" \
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
engine/cmake_install.cmake engine/Makefile engine/systemshock \
engine/src/Libraries/CMakeFiles \
+16
View File
@@ -16,3 +16,19 @@ The Android build additionally bundles:
- The Khronos Group's OpenXR-SDK loader
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
The Windows build (cross-compiled via MinGW - see README's "4.2. Windows
cross-build") additionally bundles:
- GLEW (http://glew.sourceforge.net/), unmodified, as glew32.dll - used
for OpenGL extension loading, which Windows' own opengl32.dll doesn't
provide past OpenGL 1.1 (Linux instead gets this straight from Mesa's
headers, needing no separate loader library - see
engine/src/MacSrc/OpenGL.cc). It is licensed under a combination of the
Modified BSD License, the MIT License, and the Khronos License, all
permissive.
- The MinGW-w64 runtime's winpthreads library, as libwinpthread-1.dll. It
is MIT-licensed. (GCC's own runtime, libgcc/libstdc++, is linked
statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.)
+80 -18
View File
@@ -92,10 +92,30 @@ below.
## 4. Desktop build
Builds and runs Questshock natively on Linux (your dev machine, or any
Linux box) - useful for local development and testing without a VR
headset at all. For the VR-headset build, see "5. Android / Quest build"
below instead.
Builds and runs Questshock natively on the desktop (your dev machine, or
any Linux/Windows box) - useful for local development and testing
without a VR headset at all. For the VR-headset build, see "5. Android /
Quest build" below instead.
Two platforms: Linux (native) and Windows (x86_64, cross-compiled via
MinGW - see "4.2. Windows cross-build" below for how). `make dist`/`make
package` build **both** by default; use `make dist-linux`/`make
dist-win` (or `package-linux`/`package-win`) to build just one.
**Prerequisites:**
- **Docker** - `build-image.sh`/`run-image.sh` build and run the engine
build-image; no other build tool touches the host directly. The
Windows cross-toolchain (MinGW) is baked into the same build-image, so
building `dist-win` needs nothing extra beyond Docker itself.
- **`innoextract` and `unzip`** - optional, only needed by
`res/assets/extract_assets.sh` (see "3. Game assets" above) to unpack
the GOG installer locally; if either is missing, that script falls
back to running the extraction inside a throwaway Docker container
instead.
- **`zip`** - only needed on the host for `make package`/`package-win`
(the Windows release archive); `make dist`/`dist-win` and
`package-linux` don't need it.
```sh
# 1. Build the engine build-image (once, or after build-image/ changes)
@@ -104,11 +124,12 @@ below instead.
# 2. Get your own copy of the game data (see "3. Game assets" above), then:
res/assets/extract_assets.sh
# 3. Compile the engine and assemble dist/
# 3. Compile the engine and assemble dist/ (Linux) and dist-win/ (Windows)
make dist
# 4. Play
dist/run.sh
dist/run.sh # Linux
dist-win/run.bat # Windows (or systemshock.exe directly)
```
`make dist` always recompiles the engine from the current `engine/`
@@ -117,14 +138,38 @@ source (via `run-image.sh`), so a fresh build-image plus a re-run of
### 4.1. Packaging a distributable build
`make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the
compiled binary, its runtime libraries, shaders, a default MIDI
soundfont, license information, and `res/GET_ASSETS.txt` in place of the
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
it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (see
"5. Android / Quest build" below) is versioned identically, via the same
`build-image/version.sh`.
`make package` builds both `dist/shockolate-<version>-linux-<arch>.tar.gz`
and `dist/shockolate-<version>-windows-x86_64.zip`: the compiled
binary/DLLs, shaders, a default MIDI soundfont, license information, and
`res/GET_ASSETS.txt` in place of the actual game data (which neither
archive ever includes). Version comes from the current git tag (push a
`vX.Y.Z` tag to drive a release); without one it builds an untagged
`0.0.0-dev+<sha>` placeholder. `make apk` (see "5. Android / Quest build"
below) is versioned identically, via the same `build-image/version.sh`.
### 4.2. Windows cross-build
`make dist-win`/`make package-win` cross-compile a native Windows x86_64
build via MinGW (`x86_64-w64-mingw32-gcc`/`g++`, baked into the
build-image alongside the Linux toolchain - see
`build-image/build-engine-win.sh` and the "Windows cross-compile" section
of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
in building it. `engine/CMakeLists.txt` already has working `WIN32`/
`MINGW` branches from Shockolate's own upstream Windows build (built
natively via Git Bash/MinGW on a real Windows machine - see
`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest
build's `android/engine-patches/`, no source patching is needed here -
this cross-compiles those same branches offline and reproducibly, from
Linux, via Docker.
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
sitting flat alongside it, rather than in a `lib/` subdirectory like
`dist/` - Windows' default DLL search order already checks the
executable's own directory first, so no `PATH`/library-path setup is
needed the way `dist/run.sh` needs `LD_LIBRARY_PATH`.
Confirmed working (and playable) on a real Windows machine.
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
package on every push, using the build-image as its container (so no
@@ -133,6 +178,18 @@ resulting tarball to dl.ladkau.de.
## 5. Android / Quest build
**Prerequisites:**
- **Docker** - same build-image as the desktop build (see "4. Desktop
build" above), plus network access at build time for Gradle/AGP's own
dependency resolution (the one target that isn't fully offline).
- **SideQuest or `adb`** (Android Platform Tools) - to sideload the built
APK onto the headset, and to enable/verify USB debugging; see "5.1.
Installing and playing" below.
- Building/debugging natively in Android Studio instead of via `make
apk` needs its own separate toolchain - see "5.2. Building natively in
Android Studio" below.
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
can be sideloaded onto any Android-based VR headset with OpenXR support
@@ -184,10 +241,15 @@ set only on the keyboard's layer - the menu launcher stays fully opaque)
so whatever's behind it - the game quad, mid-play - stays visible while
it's up.
The laser
pointer/cursor is currently only visible while
actually aiming at the game or menu quad respectively - there's no visual
feedback yet while aiming at empty space between them. (Only tested on
While the menu launcher or keyboard is open, each hand also gets a thin,
colored laser-beam quad (cyan left, amber right) from the controller to
wherever its ray currently crosses that panel's plane - a second,
billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in
`xr_input.c`), oriented so it reads as a line from the viewer's eye
regardless of angle, since the flat cross-shaped reticle used while
aiming at the game quad is drawn directly into the game quad's own
texture and can't represent a ray traversing real 3D space. Aiming at the
game quad itself still uses that flat reticle, unchanged. (Only tested on
Meta Quest so far -
the steps below use Quest-specific tool names where relevant, but the
same `adb install` flow applies to any Android headset with USB
+1 -1
View File
@@ -171,7 +171,7 @@ android {
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-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;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c"
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c"
abiFilters 'arm64-v8a'
}
}
+375 -115
View File
@@ -7,8 +7,10 @@
#include <GLES3/gl3.h>
#include "xr_mouse.h"
#include "xr_overlay.h"
#include "xr_session.h"
#include "xr_swapchain.h"
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
@@ -52,6 +54,7 @@ static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
static bool g_prev_select[2] = {false, false};
static bool g_prev_menu = false;
static bool g_game_prev_hit[2] = {false, false};
static bool g_game_touch_active[2] = {false, false};
static bool g_menu_prev_hit[2] = {false, false};
static bool g_menu_touch_active[2] = {false, false};
static bool g_keyboard_prev_hit[2] = {false, false};
@@ -69,8 +72,28 @@ static int g_keyboard_drag_hand = -1;
static float g_keyboard_drag_offset_x = 0.0f;
static float g_keyboard_drag_offset_y = 0.0f;
static GLuint g_reticle_program = 0;
static GLint g_reticle_color_loc = -1;
// A "view" reference space, located once per frame (not per hand) to get
// an approximate head position for the laser-beam billboard math below -
// the only consumer of a head pose in this file. Both LOCAL (g_local_space,
// owned by xr_session.c) and VIEW reference spaces are mandated by core
// OpenXR, so no capability check is needed to create this.
static XrSpace g_view_space = XR_NULL_HANDLE;
// Per-hand laser-beam state: a thin, billboarded quad spanning from the
// controller to wherever that hand's ray currently crosses the plane of
// whichever target (keyboard, menu, or the game quad) claimed it this
// frame - see xr_input_build_beam()/xr_input_get_beam_layer(). Each hand
// gets its own tiny solid-color swapchain (no shared tint on XrCompositionLayerQuad,
// so two separately-colored textures is simplest). g_beam_quad_valid is
// reset to false at the top of every xr_input_sync_and_draw() call and
// only set back to true if that hand actually claims a beam this frame.
#define BEAM_TEX_SIZE 2
#define BEAM_THICKNESS_METERS 0.004f
#define MAX_BEAM_LENGTH_METERS 2.0f
static XrSwapchainState g_beam_swapchain[2];
static XrCompositionLayerQuad g_beam_quad[2];
static bool g_beam_quad_valid[2] = {false, false};
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
@@ -95,64 +118,71 @@ static void quat_rotate_vec(const XrQuaternionf *q, float vx, float vy, float vz
*outz = vz + 2.0f * (q->x * cy - q->y * cx);
}
static GLuint compile_shader(GLenum type, const char *src) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &src, NULL);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512];
glGetShaderInfoLog(shader, sizeof(log), NULL, log);
LOGE("XR: reticle shader compile failed: %s", log);
}
return shader;
static void vec3_sub(const float a[3], const float b[3], float out[3]) {
out[0] = a[0] - b[0];
out[1] = a[1] - b[1];
out[2] = a[2] - b[2];
}
// A standalone flat-color shader, independent of the engine's own
// textureShaderProgram (OpenGL.cc is a separate, C++-only translation
// unit, and shader state isn't shared across programs anyway) - position
// is emitted directly in clip space, matching the same [-1,1] local quad
// coordinates android_draw_surface_as_quad() (see android/engine-patches/
// 11-android-openxr-present.patch) already uses for its own vertex
// positions, so no view/projection matrix is needed here either.
static const char *kVertexSrc = "attribute vec3 position;\n"
"void main() { gl_Position = vec4(position, 1.0); }\n";
static const char *kFragmentSrc = "precision mediump float;\n"
"uniform vec4 color;\n"
"void main() { gl_FragColor = color; }\n";
static float vec3_dot(const float a[3], const float b[3]) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
// Attribute 0 to match kVertexSrc's single "position" attribute - fine to
// reuse the same numeric index the engine's own immediate-mode drawing
// treats specially, since that's a per-program binding and this program is
// never current at the same time as gl4es's immediate-mode emulation runs;
// xr_input_sync_and_draw() disables the array again right after drawing,
// the same discipline android_draw_surface_as_quad() already established.
#define RETICLE_POSITION_LOC 0
static void vec3_cross(const float a[3], const float b[3], float out[3]) {
out[0] = a[1] * b[2] - a[2] * b[1];
out[1] = a[2] * b[0] - a[0] * b[2];
out[2] = a[0] * b[1] - a[1] * b[0];
}
static bool xr_input_init_reticle_program(void) {
GLuint vs = compile_shader(GL_VERTEX_SHADER, kVertexSrc);
GLuint fs = compile_shader(GL_FRAGMENT_SHADER, kFragmentSrc);
g_reticle_program = glCreateProgram();
glAttachShader(g_reticle_program, vs);
glAttachShader(g_reticle_program, fs);
glBindAttribLocation(g_reticle_program, RETICLE_POSITION_LOC, "position");
glLinkProgram(g_reticle_program);
GLint linked = GL_FALSE;
glGetProgramiv(g_reticle_program, GL_LINK_STATUS, &linked);
glDeleteShader(vs);
glDeleteShader(fs);
if (!linked) {
char log[512];
glGetProgramInfoLog(g_reticle_program, sizeof(log), NULL, log);
LOGE("XR: reticle program link failed: %s", log);
// Returns false (out left untouched) if v is too close to zero-length to
// normalize safely - callers use this to detect a degenerate billboard
// axis and fall back to another reference vector.
static bool vec3_normalize(const float v[3], float out[3]) {
float len = sqrtf(vec3_dot(v, v));
if (len < 1e-6f)
return false;
}
g_reticle_color_loc = glGetUniformLocation(g_reticle_program, "color");
out[0] = v[0] / len;
out[1] = v[1] / len;
out[2] = v[2] / len;
return true;
}
bool xr_input_init(XrInstance instance, XrSession session) {
// Converts an orthonormal local->world rotation matrix (given as its
// columns - x/y/z, each the world-space direction of that local axis) to
// an XrQuaternionf, via the standard trace-based (Shepperd) method.
static void mat3_to_quat(const float x[3], const float y[3], const float z[3], XrQuaternionf *q) {
float m00 = x[0], m10 = x[1], m20 = x[2];
float m01 = y[0], m11 = y[1], m21 = y[2];
float m02 = z[0], m12 = z[1], m22 = z[2];
float trace = m00 + m11 + m22;
if (trace > 0.0f) {
float s = sqrtf(trace + 1.0f) * 2.0f;
q->w = 0.25f * s;
q->x = (m21 - m12) / s;
q->y = (m02 - m20) / s;
q->z = (m10 - m01) / s;
} else if (m00 > m11 && m00 > m22) {
float s = sqrtf(1.0f + m00 - m11 - m22) * 2.0f;
q->w = (m21 - m12) / s;
q->x = 0.25f * s;
q->y = (m01 + m10) / s;
q->z = (m02 + m20) / s;
} else if (m11 > m22) {
float s = sqrtf(1.0f + m11 - m00 - m22) * 2.0f;
q->w = (m02 - m20) / s;
q->x = (m01 + m10) / s;
q->y = 0.25f * s;
q->z = (m12 + m21) / s;
} else {
float s = sqrtf(1.0f + m22 - m00 - m11) * 2.0f;
q->w = (m10 - m01) / s;
q->x = (m02 + m20) / s;
q->y = (m12 + m21) / s;
q->z = 0.25f * s;
}
}
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format) {
g_instance = instance;
g_session = session;
@@ -246,9 +276,21 @@ bool xr_input_init(XrInstance instance, XrSession session) {
if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets"))
return false;
if (!xr_input_init_reticle_program())
XrReferenceSpaceCreateInfo viewSpaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
viewSpaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
viewSpaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
if (!xr_check(xrCreateReferenceSpace(session, &viewSpaceInfo, &g_view_space),
"xrCreateReferenceSpace(view)"))
return false;
for (int hand = 0; hand < 2; hand++) {
if (!xr_swapchain_create(instance, session, swapchain_format, BEAM_TEX_SIZE, BEAM_TEX_SIZE,
&g_beam_swapchain[hand])) {
LOGE("XR: beam swapchain setup failed for hand %d", hand);
return false;
}
}
LOGI("XR: input action set ready (aim pose + trigger + menu-toggle)");
return true;
}
@@ -258,18 +300,31 @@ bool xr_input_init(XrInstance instance, XrSession session) {
// below. dragCapable enables the keyboard-only title-bar/drag-handle
// handling (see TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION); the menu
// launcher has no title bar, so it's always false there and every hit is a
// plain click. Returns true if this overlay is visible - claiming the
// hand's ray processing for this frame, regardless of whether the ray
// actually hits it - so the caller should stop trying other targets (menu/
// keyboard/game quad are mutually exclusive per hand, per frame).
// plain click. Returns true only when this hand's ray is actually
// relevant to this overlay this frame - landing on it now, or continuing
// a touch/drag begun on a previous frame for this hand that hasn't been
// released yet - not merely because the overlay is visible. The caller
// should only stop trying other targets (menu/keyboard/game quad) when
// this returns true; a visible-but-unclaimed overlay falls through so a
// lower-priority target (ultimately the game quad) can still be aimed at.
static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
const XrSpaceLocation *location, float fx, float fy, float fz,
bool selectDownEdge, bool selectUpEdge, bool *touchActive,
bool *prevHit, const char *quadName, float *outCursorU,
float *outCursorV, bool *outCursorHit) {
float *outCursorV, bool *outCursorHit, bool *outPlaneHit,
float *outPlaneDistance) {
*outPlaneHit = false;
if (!xr_overlay_is_visible(overlay))
return false;
// Captured before this call's own down/up-edge handling below can
// mutate them, so a hand with a pending down-touch (waiting for its
// matching up) or an in-progress drag on *this* overlay still claims
// the ray this frame even if it has strayed off the panel's current
// bounds - see `claims` below.
bool hadTouch = *touchActive;
bool hadDrag = dragCapable && g_keyboard_dragging && g_keyboard_drag_hand == hand;
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight;
xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth,
&halfHeight);
@@ -283,9 +338,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
// view's pixel grid and the on-quad hit logs use) are only meaningful
// within the quad's current bounds, unlike planeHit.
bool planeHit = false, hit = false;
float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f;
float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f, t = 0.0f;
if (fabsf(fz) > 1e-5f) {
float t = (-distance - location->pose.position.z) / fz;
t = (-distance - location->pose.position.z) / fz;
if (t > 0.0f) {
planeHit = true;
worldX = location->pose.position.x + t * fx;
@@ -297,6 +352,8 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
v = (1.0f - cy) * 0.5f;
}
}
*outPlaneHit = planeHit;
*outPlaneDistance = t;
if (hit != *prevHit) {
LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand],
@@ -309,6 +366,12 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
*outCursorV = v;
}
// Only claim (and thus route/beam) this hand's ray to this overlay
// when it's actually relevant this frame: landing on it now, or
// continuing a touch/drag that started on it - never merely because
// it's visible.
bool claims = hit || hadTouch || hadDrag;
if (!dragCapable) {
if (selectDownEdge && hit) {
xr_overlay_touch(overlay, u, v, true);
@@ -317,7 +380,7 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
xr_overlay_touch(overlay, u, v, false);
*touchActive = false;
}
return true;
return claims;
}
bool onTitleBar = hit && v < TITLE_BAR_V_FRACTION;
@@ -350,7 +413,157 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
xr_overlay_set_position(overlay, worldX - g_keyboard_drag_offset_x,
worldY - g_keyboard_drag_offset_y);
}
return true;
return claims;
}
// Builds this hand's laser-beam quad - a thin, billboarded ribbon from the
// controller (location->pose.position) along the aim ray (fx,fy,fz,
// already unit length) to length_m meters out, oriented so it reads as a
// line from the viewer's eye (headLoc->pose.position) regardless of angle:
// one in-plane axis follows the ray direction, the other is the thin
// "width", and the quad's normal is billboarded toward the eye
// (orthogonalized against the ray axis, so the ribbon only rotates around
// its own long axis as the hand moves, never twists). Acquires/clears/
// releases this hand's tiny beam swapchain and leaves g_beam_quad[hand]
// ready for xr_input_get_beam_layer() - doesn't set space/eyeVisibility/
// layerFlags (see that function's doc comment in xr_input.h).
static void xr_input_build_beam(int hand, const XrSpaceLocation *location, float fx, float fy,
float fz, float length_m, const XrSpaceLocation *headLoc) {
float origin[3] = {location->pose.position.x, location->pose.position.y,
location->pose.position.z};
float dirY[3] = {fx, fy, fz};
float endpoint[3] = {origin[0] + length_m * fx, origin[1] + length_m * fy,
origin[2] + length_m * fz};
float center[3] = {(origin[0] + endpoint[0]) * 0.5f, (origin[1] + endpoint[1]) * 0.5f,
(origin[2] + endpoint[2]) * 0.5f};
float head[3] = {headLoc->pose.position.x, headLoc->pose.position.y,
headLoc->pose.position.z};
float toEyeRaw[3], toEye[3];
vec3_sub(head, center, toEyeRaw);
if (!vec3_normalize(toEyeRaw, toEye)) {
// Head is (almost) exactly at the beam's midpoint - astronomically
// unlikely, but fall back to a fixed direction rather than divide
// by ~0.
toEye[0] = 0.0f;
toEye[1] = 0.0f;
toEye[2] = 1.0f;
}
// Orthogonalize toEye against the ray axis to get the billboard
// normal - if the ray points almost straight at/away from the eye,
// that leaves ~nothing to normalize, so fall back to world-up then
// world-Z, each reprojected the same way.
float normalZ[3];
float d = vec3_dot(toEye, dirY);
float proj[3] = {toEye[0] - dirY[0] * d, toEye[1] - dirY[1] * d, toEye[2] - dirY[2] * d};
if (!vec3_normalize(proj, normalZ)) {
static const float kWorldUp[3] = {0.0f, 1.0f, 0.0f};
d = vec3_dot(kWorldUp, dirY);
proj[0] = kWorldUp[0] - dirY[0] * d;
proj[1] = kWorldUp[1] - dirY[1] * d;
proj[2] = kWorldUp[2] - dirY[2] * d;
if (!vec3_normalize(proj, normalZ)) {
static const float kWorldZ[3] = {0.0f, 0.0f, 1.0f};
d = vec3_dot(kWorldZ, dirY);
proj[0] = kWorldZ[0] - dirY[0] * d;
proj[1] = kWorldZ[1] - dirY[1] * d;
proj[2] = kWorldZ[2] - dirY[2] * d;
if (!vec3_normalize(proj, normalZ)) {
// dirY parallel to both world-up and world-Z is impossible
// for two non-parallel vectors - unreachable in practice,
// but keep the beam well-defined regardless.
normalZ[0] = 1.0f;
normalZ[1] = 0.0f;
normalZ[2] = 0.0f;
}
}
}
float axisX[3];
vec3_cross(dirY, normalZ, axisX);
if (!vec3_normalize(axisX, axisX)) {
axisX[0] = 1.0f;
axisX[1] = 0.0f;
axisX[2] = 0.0f;
}
float axisZ[3];
vec3_cross(axisX, dirY, axisZ); // already unit length - axisX/dirY are orthonormal
XrQuaternionf orientation;
mat3_to_quat(axisX, dirY, axisZ, &orientation);
if (xr_swapchain_acquire(g_instance, &g_beam_swapchain[hand])) {
// Premultiplied alpha (matches OverlayPanel.compositeAndPublish()'s
// convention, and XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT's
// assumption - see xr_session.c) - kHandColor's RGB scaled by this
// beam's own alpha, not the opaque RGB itself.
const float alpha = 0.55f;
const float *c = kHandColor[hand];
glClearColor(c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha);
glClear(GL_COLOR_BUFFER_BIT);
xr_swapchain_release(g_instance, &g_beam_swapchain[hand]);
}
XrCompositionLayerQuad *quad = &g_beam_quad[hand];
quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
quad->next = NULL;
quad->subImage.swapchain = g_beam_swapchain[hand].swapchain;
quad->subImage.imageRect.offset.x = 0;
quad->subImage.imageRect.offset.y = 0;
quad->subImage.imageRect.extent.width = BEAM_TEX_SIZE;
quad->subImage.imageRect.extent.height = BEAM_TEX_SIZE;
quad->subImage.imageArrayIndex = 0;
quad->pose.position.x = center[0];
quad->pose.position.y = center[1];
quad->pose.position.z = center[2];
quad->pose.orientation = orientation;
quad->size.width = BEAM_THICKNESS_METERS;
quad->size.height = length_m;
g_beam_quad_valid[hand] = true;
}
// Hit-tests this hand's ray against the game quad (xr_get_game_quad_extent())
// - the lowest-priority target, tried only once neither the keyboard nor
// menu overlay claimed the ray this frame (or, symmetrically, once this
// hand already has a pending mouse-down on the game quad - see
// g_game_touch_active and the keyboard/menu call sites in
// xr_input_sync_and_draw()). outU/outV (only meaningful when the
// returned hit is true) let the caller drive the game's own mouse cursor
// via xr_mouse.h. Returns plain `hit` - drag/touch dispatch for actual
// clicks is the caller's responsibility (xr_mouse_click()), not this
// function's, unlike xr_input_try_overlay().
static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, float fx, float fy,
float fz, bool *outPlaneHit, float *outPlaneDistance,
float *outU, float *outV) {
float distance, halfWidth, halfHeight;
xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight);
bool planeHit = false, hit = false;
float u = 0.0f, v = 0.0f, t = 0.0f;
if (fabsf(fz) > 1e-5f) {
t = (-distance - location->pose.position.z) / fz;
if (t > 0.0f) {
planeHit = true;
float cx = (location->pose.position.x + t * fx) / halfWidth;
float cy = (location->pose.position.y + t * fy) / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
}
}
*outPlaneHit = planeHit;
*outPlaneDistance = t;
*outU = u;
*outV = v;
if (hit != g_game_prev_hit[hand]) {
LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", u, v);
g_game_prev_hit[hand] = hit;
}
return hit;
}
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
@@ -375,16 +588,26 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
g_keyboard_drag_hand = -1;
}
// The GL reticle is drawn directly into whatever framebuffer is
// currently bound - the game quad's swapchain image (see xr_frame_end(),
// which calls this while that image is still bound). That only makes
// sense while aiming at the game quad; the menu/keyboard overlays' own
// cursors are drawn by their Java views instead (see
// xr_overlay_update_cursor() below), composited into their Bitmaps the
// same way their other content is.
bool drawReticle = draw && !menuVisible && !keyboardVisible;
if (drawReticle)
glUseProgram(g_reticle_program);
// A hand only ever gets a laser-beam quad this frame if it actually
// claims the keyboard or menu overlay's ray below (see the
// xr_input_build_beam() calls) - reset here so a hand that doesn't
// claim one this frame doesn't keep showing last frame's beam.
g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false;
// The billboard math needs an approximate head position - only bother
// locating it on frames where a beam could possibly be drawn at all
// (i.e. any drawn frame - the game quad can claim a beam on its own
// even with both overlays closed).
bool needBeams = draw;
XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION};
bool haveHead = false;
if (needBeams) {
const XrSpaceLocationFlags neededHead =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
haveHead = xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc),
"xrLocateSpace(view)") &&
(headLoc.locationFlags & neededHead) == neededHead;
}
// Fed to xr_overlay_update_cursor() after the loop below - whichever
// hand's ray hits a given overlay last wins if both do, good enough
@@ -452,53 +675,81 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
quat_rotate_vec(&location.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz);
// Keyboard first (it's the more likely target while it's up), then
// the menu launcher, then - only if neither is visible - the game
// quad's own reticle below. A single ray only ever interacts with
// one target per hand per frame.
if (xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz,
// the menu launcher, then - only once neither actually claims the
// ray this frame (not merely "isn't visible" - see
// xr_input_try_overlay()'s doc comment) - the game quad itself.
// Whichever target claims the ray gets a laser-beam quad built for
// it (if a head pose is available and the ray actually crosses
// that target's plane) - see xr_input_build_beam(). Keyboard/menu
// are additionally gated on !g_game_touch_active[hand]: a pending
// mouse-down on the game quad (trigger still held since a
// down-edge dispatched there) must keep claiming the ray even if
// it strays onto another panel before release, the cross-target
// analogue of xr_input_try_overlay()'s own hadTouch/hadDrag - the
// eventual mouse-up has to reach the game quad, not whatever the
// ray happens to be over on the release frame.
bool keyboardPlaneHit = false;
float keyboardPlaneDistance = 0.0f;
if (!g_game_touch_active[hand] &&
xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz,
selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand],
&g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU,
&keyboardCursorV, &keyboardCursorHit))
&keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit,
&keyboardPlaneDistance)) {
if (haveHead && keyboardPlaneHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
continue;
if (xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
}
bool menuPlaneHit = false;
float menuPlaneDistance = 0.0f;
if (!g_game_touch_active[hand] &&
xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand],
"menu", &menuCursorU, &menuCursorV, &menuCursorHit))
"menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit,
&menuPlaneDistance)) {
if (haveHead && menuPlaneHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
continue;
float distance, halfWidth, halfHeight;
xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight);
bool hit = false;
float u = 0.0f, v = 0.0f, cx = 0.0f, cy = 0.0f;
if (fabsf(fz) > 1e-5f) {
float t = (-distance - location.pose.position.z) / fz;
if (t > 0.0f) {
cx = (location.pose.position.x + t * fx) / halfWidth;
cy = (location.pose.position.y + t * fy) / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
}
}
if (hit != g_game_prev_hit[hand]) {
LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", u, v);
g_game_prev_hit[hand] = hit;
// Lowest priority (for a fresh hit): the game quad itself - drives
// the engine's own mouse cursor/clicks via xr_mouse.h, exactly
// like a desktop mouse (absolute position + left button), plus the
// same laser-beam visual the other two targets get.
bool gamePlaneHit = false;
float gamePlaneDistance = 0.0f, gameU = 0.0f, gameV = 0.0f;
bool gameHit = xr_input_try_game_quad(hand, &location, fx, fy, fz, &gamePlaneHit,
&gamePlaneDistance, &gameU, &gameV);
if (gameHit) {
int gameWidth, gameHeight;
xr_get_game_resolution(&gameWidth, &gameHeight);
int px = (int)(gameU * (float)gameWidth);
int py = (int)(gameV * (float)gameHeight);
if (px < 0)
px = 0;
else if (px >= gameWidth)
px = gameWidth - 1;
if (py < 0)
py = 0;
else if (py >= gameHeight)
py = gameHeight - 1;
xr_mouse_move(px, py);
}
if (!hit || !drawReticle)
continue;
const float kSize = 0.03f;
const float verts[] = {
cx - kSize, cy, 0.0f, cx + kSize, cy, 0.0f, cx, cy - kSize, 0.0f, cx, cy + kSize, 0.0f,
};
glUniform4fv(g_reticle_color_loc, 1, kHandColor[hand]);
glEnableVertexAttribArray(RETICLE_POSITION_LOC);
glVertexAttribPointer(RETICLE_POSITION_LOC, 3, GL_FLOAT, GL_FALSE, 0, verts);
glDrawArrays(GL_LINES, 0, 4);
glDisableVertexAttribArray(RETICLE_POSITION_LOC);
if (selectDownEdge && gameHit) {
xr_mouse_click(true);
g_game_touch_active[hand] = true;
} else if (selectUpEdge && g_game_touch_active[hand]) {
xr_mouse_click(false);
g_game_touch_active[hand] = false;
}
// gameHit implies gamePlaneHit (both only ever set together above),
// kept as a separate out-param for symmetry with
// xr_input_try_overlay()'s outPlaneHit/outPlaneDistance pair.
if (haveHead && gameHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(gamePlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
}
if (menuVisible)
@@ -508,12 +759,23 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
keyboardCursorHit);
}
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad) {
if (hand < 0 || hand > 1 || !g_beam_quad_valid[hand])
return false;
*out_quad = g_beam_quad[hand];
return true;
}
void xr_input_shutdown(void) {
for (int hand = 0; hand < 2; hand++) {
if (g_aim_space[hand] != XR_NULL_HANDLE)
xrDestroySpace(g_aim_space[hand]);
g_aim_space[hand] = XR_NULL_HANDLE;
xr_swapchain_destroy(&g_beam_swapchain[hand]);
}
if (g_view_space != XR_NULL_HANDLE)
xrDestroySpace(g_view_space);
g_view_space = XR_NULL_HANDLE;
if (g_action_set != XR_NULL_HANDLE)
xrDestroyActionSet(g_action_set);
g_action_set = XR_NULL_HANDLE;
@@ -521,18 +783,16 @@ void xr_input_shutdown(void) {
g_select_click_action = XR_NULL_HANDLE;
g_menu_toggle_action = XR_NULL_HANDLE;
if (g_reticle_program != 0)
glDeleteProgram(g_reticle_program);
g_reticle_program = 0;
g_instance = XR_NULL_HANDLE;
g_session = XR_NULL_HANDLE;
memset(g_prev_select, 0, sizeof(g_prev_select));
memset(g_game_prev_hit, 0, sizeof(g_game_prev_hit));
memset(g_game_touch_active, 0, sizeof(g_game_touch_active));
memset(g_menu_prev_hit, 0, sizeof(g_menu_prev_hit));
memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active));
memset(g_keyboard_prev_hit, 0, sizeof(g_keyboard_prev_hit));
memset(g_keyboard_touch_active, 0, sizeof(g_keyboard_touch_active));
g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false;
g_prev_menu = false;
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
+43 -17
View File
@@ -1,17 +1,30 @@
// Controller input for questshock's immersive Quest build: one OpenXR
// action set (aim pose + trigger click per hand, a menu-toggle button on
// the left controller) plus ray/quad hit-testing. While neither the menu
// launcher nor keyboard overlay (see xr_overlay.h, xr_session.c) is
// visible, this tests against the game quad xr_session.c submits and draws
// a small reticle where each hand's aim ray crosses it; while either
// overlay is visible, it tests against that overlay's quad instead (the
// keyboard is tried first) and forwards trigger edges as synthetic touches
// (no reticle - each overlay's own content comes from its Java view's
// rendered Bitmap).
// the left controller) plus ray/quad hit-testing. Each hand's ray is
// tried against the keyboard overlay, then the menu overlay (see
// xr_overlay.h, xr_session.c), then - only if neither actually claims it
// this frame (landing on it now, or continuing a touch/drag begun on a
// previous frame - not merely because that overlay happens to be visible)
// - the game quad xr_session.c submits, forwarding trigger edges to
// whichever overlay claims the ray as synthetic touches. The game quad
// itself acts as a plain desktop-style mouse pointer into the engine's
// own UI (see xr_mouse.h) - absolute cursor position while the ray hits
// it, plus a left click on the trigger edge; a pending click there
// likewise keeps claiming the ray ahead of the keyboard/menu until
// released, so the eventual mouse-up isn't lost if the ray strays. Since
// none of the three targets' own feedback (the overlays' on-quad cursor,
// drawn by their Java views; the engine's own mouse cursor sprite for the
// game quad) gives any indication while the ray is short of actually
// landing on something, every claimed target also builds a thin,
// billboarded laser-beam quad per hand from the controller to wherever
// the ray currently crosses that target's plane (see
// xr_input_get_beam_layer()) - so aiming looks and behaves the same
// whether the target is an overlay or the game quad itself.
#ifndef QUESTSHOCK_XR_INPUT_H
#define QUESTSHOCK_XR_INPUT_H
#include <stdbool.h>
#include <stdint.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
@@ -24,22 +37,35 @@ extern "C" {
// Call once, right after the session is created (see xr_session.c's
// xr_create_instance_and_session()) - creates the action set/actions,
// suggests Touch controller bindings, creates the per-hand aim action
// spaces, and attaches the set to the session. Returns false (logged,
// non-fatal - the caller keeps rendering without input) if any of that
// fails.
bool xr_input_init(XrInstance instance, XrSession session);
// spaces and a view-space reference (for the laser-beam billboard math),
// creates the two per-hand beam swapchains (swapchain_format - the same
// format shared by the game swapchain and both overlays), and attaches
// the action set to the session. Returns false (logged, non-fatal - the
// caller keeps rendering without input) if any of that fails.
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format);
// Call once per frame from xr_frame_end(), before releasing the acquired
// swapchain image - syncs this frame's action states (always, so edge
// detection stays correct even on frames with nothing to draw), handles
// the menu_toggle button's edge, and either draws a game-quad reticle or
// forwards menu-quad touches, per the menu's current visibility (see the
// file comment above). draw gates only the reticle - if false (nothing to
// draw into this frame, e.g. no swapchain image was acquired), hit-testing
// and touch-forwarding still run. baseSpace/time must match whatever
// the menu_toggle button's edge, and hit-tests/dispatches each hand's ray
// against keyboard/menu/game quad in that priority order (see the file
// comment above). draw gates only the laser-beam visuals (and the
// head-pose locate that feeds them) - if false (nothing to draw into this
// frame, e.g. no swapchain image was acquired), hit-testing and
// touch-forwarding still run. baseSpace/time must match whatever
// xr_frame_begin() used to predict this frame.
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// Call once per hand (hand: 0=left, 1=right) after xr_input_sync_and_draw()
// in the same frame - if that hand's ray claimed the keyboard, menu, or
// game quad this frame (see the file comment above), fills *out_quad's
// subImage/pose/size for its laser-beam ribbon and returns true.
// space/eyeVisibility/layerFlags are left for the caller to set, same
// convention as xr_overlay_render_and_build_layer(). Returns false
// (out_quad untouched) if no beam should be shown for that hand this
// frame.
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
void xr_input_shutdown(void);
#ifdef __cplusplus
+20
View File
@@ -0,0 +1,20 @@
#include "xr_mouse.h"
#include <SDL.h>
#include "mouse.h" // engine/src/Libraries/INPUT/Source/mouse.h - mouse_put_xy()
void xr_mouse_move(int x, int y) { mouse_put_xy((short)x, (short)y); }
void xr_mouse_click(bool down) {
SDL_Event event;
SDL_zero(event);
event.type = down ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP;
event.button.timestamp = SDL_GetTicks();
event.button.windowID = 0;
event.button.which = 0;
event.button.button = SDL_BUTTON_LEFT;
event.button.state = down ? SDL_PRESSED : SDL_RELEASED;
event.button.clicks = 1;
SDL_PushEvent(&event);
}
+35
View File
@@ -0,0 +1,35 @@
// Bridges VR aim-ray hit-testing (xr_input.c) to the vendored engine's
// mouse input (engine/src/Libraries/INPUT/Source/mouse.h) - lets the
// controller's laser pointer drive the game's own UI (inventory, menus,
// dialogs) exactly like a desktop mouse: absolute cursor position plus a
// single left button, no relative/mouselook mode, no right button.
#ifndef QUESTSHOCK_XR_MOUSE_H
#define QUESTSHOCK_XR_MOUSE_H
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Sets the engine's absolute mouse position (mouse_put_xy()) - call every
// frame the aim ray is actually hitting the game quad; x/y are pixel
// coordinates in the game's own logical resolution (see
// xr_get_game_resolution() in xr_session.h), already clamped by the
// caller. Do not call on frames the ray isn't hitting the game quad - the
// cursor should hold its last position, not snap elsewhere.
void xr_mouse_move(int x, int y);
// Pushes a synthetic SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) - matches
// KeyboardOverlay's nativeSendPrintableChar() in questshock_native.c: a
// plain SDL_Event built by hand and handed to the public SDL_PushEvent(),
// no engine patch needed. pump_events() (sdl_events.c) reads whatever
// mouse_put_xy() last set, not any x/y carried on the event itself - call
// xr_mouse_move() first if position needs updating this frame.
void xr_mouse_click(bool down);
#ifdef __cplusplus
}
#endif
#endif
+42 -21
View File
@@ -213,19 +213,16 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace"))
return false;
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
// keeps working either way, just without the laser pointer.
xr_input_init(g_instance, g_session);
// 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. Every swapchain below (game quad, menu/keyboard overlays)
// shares this one choice - the compositor's supported format set
// doesn't depend on swapchain size.
// data. Every swapchain below (game quad, menu/keyboard overlays, and
// - via xr_input_init() - the per-hand laser-beam overlays) shares
// this one choice - the compositor's supported format set doesn't
// depend on swapchain size.
uint32_t formatCount = 0;
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
@@ -240,6 +237,10 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
}
free(formats);
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
// keeps working either way, just without the laser pointer/beams.
xr_input_init(g_instance, g_session, chosenFormat);
if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
&g_game_swapchain)) {
LOGE("XR: game swapchain setup failed");
@@ -349,14 +350,13 @@ void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
// Draw the laser-pointer reticle into the still-bound game swapchain
// framebuffer before releasing it, so it composites on top of whatever
// this frame's game content already drew there. Syncing actions
// happens even when there's nothing to draw (no acquired image this
// frame), so edge detection (trigger/menu-button clicks) doesn't miss a
// frame. Only ever draws while neither overlay is visible (see
// xr_input.c) - the menu/keyboard render into their own independent
// swapchain images below.
// Syncs actions and hit-tests/dispatches each hand's ray against
// keyboard/menu/game quad (see xr_input.c) before the game swapchain
// image is released below - this always runs, even when there's
// nothing to draw (no acquired image this frame), so edge detection
// (trigger/menu-button clicks) doesn't miss a frame. Any resulting
// laser-beam quads are submitted as their own composition layers
// further down, not drawn into the game swapchain itself.
if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time,
g_have_acquired_game_image);
@@ -367,12 +367,14 @@ void xr_frame_end(void) {
if (!xr_is_session_running())
return;
// Up to 3 layers: the game quad (if a frame was actually rendered),
// and, while visible, the menu launcher and keyboard overlays - each
// gets its own acquire/render/release cycle against its own swapchain
// (see xr_overlay_render_and_build_layer()) any time before xrEndFrame,
// Up to 5 layers: the game quad (if a frame was actually rendered),
// the menu launcher and keyboard overlays while visible - each gets
// its own acquire/render/release cycle against its own swapchain (see
// xr_overlay_render_and_build_layer()) any time before xrEndFrame,
// unlike the game quad there's no per-frame engine rendering to wrap
// around here, just each overlay's own blit.
// around here, just each overlay's own blit - and, per hand, a
// laser-beam quad while that hand's ray is aimed at the keyboard,
// menu, or the game quad itself (see xr_input_get_beam_layer()).
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
@@ -385,7 +387,7 @@ void xr_frame_end(void) {
gameQuad.size.height =
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
const XrCompositionLayerBaseHeader *layers[3];
const XrCompositionLayerBaseHeader *layers[5];
uint32_t layerCount = 0;
if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
@@ -413,6 +415,20 @@ void xr_frame_end(void) {
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad;
}
// Submitted last (frontmost) so a beam doesn't z-fight against the
// panel surface it's aimed at/terminates on - see
// xr_input_get_beam_layer(). Translucent for the same reason the
// keyboard is (see comment above).
XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_local_space;
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
}
}
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
@@ -453,3 +469,8 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
*half_height_m =
QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
}
void xr_get_game_resolution(int *width, int *height) {
*width = g_game_swapchain.width;
*height = g_game_swapchain.height;
}
+7
View File
@@ -74,6 +74,13 @@ void xr_shutdown(void);
// laser pointer always matches whatever's actually visible.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m);
// Pixel resolution of the game quad's own swapchain - i.e. game_width/
// game_height as passed to xr_init() (grd_cap->w/h, see Shock.c's
// InitSDL()) - the logical coordinate space xr_input.c needs to convert a
// game-quad ray hit's normalized u,v into engine mouse coordinates (see
// xr_mouse.h).
void xr_get_game_resolution(int *width, int *height);
#ifdef __cplusplus
}
#endif
+1 -2
View File
@@ -146,8 +146,7 @@ bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
// itself created (see xr_swapchain_create()), so its own "current FBO"
// bookkeeping updates correctly and its immediate-mode draw calls
// (android_draw_surface_as_quad(), the laser reticle) land in the right
// place.
// (android_draw_surface_as_quad()) land in the right place.
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
glViewport(0, 0, state->width, state->height);
return true;
+113 -3
View File
@@ -18,6 +18,13 @@ ARG FLUIDSYNTH_LITE_REF=c539a8d9270ba5a3f7d6e460606483fc2ab1eb61
# Soundfont used for MIDI music, matching what engine/build_deps.sh itself
# fetches (a free substitute for the Windows default GM soundfont).
ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
# GLEW, for the Windows/MinGW cross-build only (engine/CMakeLists.txt's
# WIN32 branch - Windows' own opengl32.dll only exposes OpenGL 1.1, so
# anything newer needs GLEW's runtime extension loading; Linux instead
# gets modern prototypes straight from Mesa's headers, no loader needed -
# see engine/src/MacSrc/OpenGL.cc). Matches the version engine/'s own
# upstream Windows build script (build_win64.sh) used.
ARG GLEW_VERSION=2.1.0
# Gitea/GitHub Actions' JS-based actions (actions/checkout,
# actions/upload-artifact, ...) need a node binary in the container job's
# PATH - this image is otherwise pure C toolchain, so it isn't pulled in
@@ -99,6 +106,15 @@ ENV DEBIAN_FRONTEND=noninteractive
# openssh-client: the CI workflow's `sftp` publish step.
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
# unzip: extracts the Android cmdline-tools zip below.
# mingw-w64: the x86_64-w64-mingw32-{gcc,g++,windres,ar,...} cross
# toolchain for the Windows desktop build (see the "Windows cross-compile"
# section below). Ubuntu ships both a win32-thread-model and a
# posix-thread-model variant behind update-alternatives; the default
# (win32) is fine here since nothing in engine/ uses std::thread.
# zip: `make package-win`'s Windows release archive - a CI job's own
# `make package` runs inside this image as its container (see
# .gitea/workflows/build.yml), so it needs to be baked in here, not just
# available on a local dev machine's own host (see README).
#
# All apt installs deliberately live in this one RUN, first, so editing
# anything below it (in particular the Android cross-compile steps, the
@@ -111,7 +127,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
libogg-dev libvorbis-dev libasound2-dev openssh-client \
openjdk-17-jdk-headless unzip \
openjdk-17-jdk-headless unzip mingw-w64 zip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/prebuilt
@@ -150,10 +166,96 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git \
&& rm -rf .git
# General MIDI soundfont for fluidsynth playback - engine/build_deps.sh
# fetches the same file and drops it into engine/res/.
# fetches the same file and drops it into engine/res/. Shared by both the
# Linux and Windows builds below.
RUN mkdir -p soundfont \
&& curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}"
# CMake toolchain file for the Windows/MinGW cross-build below, reused at
# container-run time by build-image/build-engine-win.sh (see
# MINGW_TOOLCHAIN_FILE) to cross-compile engine/ itself the same way. GCC's
# runtime (libgcc/libstdc++) is linked statically so only the SDL2/
# SDL2_mixer/GLEW/fluidsynth-lite DLLs (plus libwinpthread, which isn't
# safe to static-link the same way) need shipping alongside systemshock.exe.
ENV MINGW_TOOLCHAIN_FILE=/opt/mingw-toolchain.cmake
RUN printf '%s\n' \
'set(CMAKE_SYSTEM_NAME Windows)' \
'set(CMAKE_SYSTEM_PROCESSOR x86_64)' \
'set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)' \
'set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)' \
'set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)' \
'set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)' \
'# LIBRARY/INCLUDE/PACKAGE deliberately left at CMake'"'"'s own' \
'# cross-compiling default (BOTH) - engine/CMakeLists.txt'"'"'s' \
'# BUNDLED SDL2/SDL2_mixer/FluidSynth find_library() calls point at' \
'# build_ext/ (outside this sysroot entirely), which ONLY would' \
'# refuse to search.' \
'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' \
'set(CMAKE_EXE_LINKER_FLAGS_INIT "-static-libgcc -static-libstdc++")' \
> "${MINGW_TOOLCHAIN_FILE}"
# SDL2 and SDL2_mixer for Windows: unlike the desktop build above, these
# are libsdl.org's own official prebuilt MinGW devel packages (headers +
# import libs + DLLs for x86_64-w64-mingw32), not built from source here -
# SDL2's autotools setup targets *nix; upstream ships MinGW builds
# pre-made instead, same as engine/'s own upstream Windows build script
# (build_win64.sh) uses. Same version pins as the Linux build above, so
# both desktop builds ship the same SDL2/SDL2_mixer release.
RUN curl -sSLO "https://www.libsdl.org/release/SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& mkdir -p /opt/prebuilt/win \
&& mv "SDL2-${SDL2_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2 \
&& rm -rf "SDL2-${SDL2_VERSION}" "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz"
RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" --exclude=Xcode \
&& mv "SDL2_mixer-${SDL2_MIXER_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2_mixer \
&& rm -rf "SDL2_mixer-${SDL2_MIXER_VERSION}" "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz"
# GLEW for Windows: compiled directly instead of via GLEW's own bundled
# cross-compile Makefile configs (config/Makefile.linux-mingw64 et al) -
# those pass raw `-soname`/`--out-implib` straight to whatever $(LD) is
# set to, which only works if LD is the real `ld` binary, not gcc-as-
# linker-driver (and that config also hardcodes a 32-bit `i686-w64-
# mingw32` host despite the "64" in its name) - simpler and more robust
# to just compile+link GLEW's one source file ourselves. Produces
# glew32.dll (to ship alongside systemshock.exe) and libglew32.dll.a (the
# MinGW import library engine/CMakeLists.txt's WIN32 branch links
# against).
RUN curl -sSL -o "glew-${GLEW_VERSION}.tgz" \
"https://sourceforge.net/projects/glew/files/glew/${GLEW_VERSION}/glew-${GLEW_VERSION}.tgz/download" \
&& tar xf "glew-${GLEW_VERSION}.tgz" \
&& cd "glew-${GLEW_VERSION}" \
&& mkdir -p /opt/prebuilt/win/glew/include/GL /opt/prebuilt/win/glew/lib \
&& x86_64-w64-mingw32-gcc -DGLEW_NO_GLU -O2 -Iinclude -c src/glew.c -o glew.o \
&& x86_64-w64-mingw32-gcc -shared \
-Wl,--out-implib,/opt/prebuilt/win/glew/lib/libglew32.dll.a \
-o /opt/prebuilt/win/glew/lib/glew32.dll \
glew.o -lopengl32 -lgdi32 -luser32 -lkernel32 \
&& cp include/GL/glew.h include/GL/wglew.h /opt/prebuilt/win/glew/include/GL/ \
&& cd .. && rm -rf "glew-${GLEW_VERSION}" "glew-${GLEW_VERSION}.tgz"
# fluidsynth-lite for Windows: same source/ref/DLL-mode patch as the
# desktop build above, cross-compiled via the MinGW toolchain file. WIN32
# skips fluidsynth-lite's own pthread dependency (see its CMakeLists.txt),
# so no libwinpthread linkage to worry about here.
RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-win \
&& cd fluidsynth-lite-win \
&& git checkout "${FLUIDSYNTH_LITE_REF}" \
&& sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \
&& rm -rf .git \
&& cd .. \
&& cmake -S fluidsynth-lite-win -B build-fluidsynth-win \
-DCMAKE_TOOLCHAIN_FILE="${MINGW_TOOLCHAIN_FILE}" \
&& cmake --build build-fluidsynth-win -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/win/fluidsynth-lite/lib /opt/prebuilt/win/fluidsynth-lite/include \
&& cp build-fluidsynth-win/src/*.dll build-fluidsynth-win/src/*.dll.a \
/opt/prebuilt/win/fluidsynth-lite/lib/ \
&& cp -a fluidsynth-lite-win/include/. /opt/prebuilt/win/fluidsynth-lite/include/ \
&& cp build-fluidsynth-win/include/fluidsynth/version.h \
/opt/prebuilt/win/fluidsynth-lite/include/fluidsynth/version.h \
&& rm -rf fluidsynth-lite-win build-fluidsynth-win
# Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run
# when this image is used as a CI job's container - see NODE_VERSION above.
RUN curl -sSL -o /tmp/node.tar.xz \
@@ -283,7 +385,15 @@ RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.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
# 755, not +x: the entrypoint drops to a `builder` user matching the
# *host's* UID/GID (see docker-entrypoint.sh), which is never in these
# root-owned files' group, so it needs the image's own explicit
# world-read+execute here - `chmod +x` alone only adds execute bits on
# top of whatever "other" permissions the source file happened to have
# (which depends on the host's umask at checkout, e.g. a restrictive
# 0007 umask yields unreadable-by-other files, which then round-trip
# into the image and cause a Permission denied at container run time).
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
# Marks a shell as already running inside this image (with every engine
# build dependency prebuilt above) - lets the Makefile's `engine` target
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Cross-compiles engine/ (the vendored Shockolate snapshot) for Windows
# (x86_64) via MinGW, against the dependencies prebuilt into this image at
# /opt/prebuilt/win - no network access needed. engine/CMakeLists.txt
# already has working WIN32/MINGW branches (the upstream Shockolate
# project's own build_win64.sh/appveyor.yml build the same way, natively
# on Windows via Git Bash/MinGW; this cross-compiles the same branches
# fully offline and reproducibly from Linux instead), so no source
# patching is needed, unlike the Quest build's android/engine-patches/.
#
# Builds from a scratch copy of engine/ (like android/engine-patches/
# does for the Quest build) rather than engine/ itself: its BUNDLED
# dependency paths (build_ext/built_sdl etc.) and in-source CMake cache
# are relative to/inside the source tree, and would otherwise collide
# with the Linux desktop build's own build_ext/CMakeCache.txt (see
# build-engine.sh) if both are built from the same checkout, as `make
# dist` (dist-linux + dist-win) does.
#
# Must be run with the repo root as the working directory - either via
# ../run-image.sh, or directly when already inside this image (see the
# Makefile's `engine-win` target, which picks whichever of these
# applies, same as `engine`).
#
# Output lands in engine/.build-output-win/: systemshock.exe, the DLLs
# it needs at runtime, and a default MIDI soundfont - everything the
# root Makefile needs to assemble dist-win/.
set -euo pipefail
REPO_ROOT="$(pwd)"
SCRATCH_ENGINE="$REPO_ROOT/build/win-engine"
OUT_DIR="$REPO_ROOT/engine/.build-output-win"
echo "== Preparing a scratch copy of engine/ for the Windows cross-build =="
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
# See build-engine.sh for why this detection is needed (e.g. a VirtualBox
# vboxsf shared folder refusing to create symlinks or preserve hard-link
# relationships) - applied here too since build/ is under the same
# bind-mounted repo checkout.
CP_FLAGS=(-a)
mkdir -p "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
if ! ln -s test-target "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir/test" 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
fi
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
cd "$SCRATCH_ENGINE"
# If engine/ has ever been built in-source directly (`make dist`/`make
# engine` - see build-engine.sh), that leftover CMakeCache.txt etc. just
# got copied along verbatim - and it still points at the real engine/
# path, not this scratch copy, which CMake refuses to configure against
# ("directory is different than the directory where CMakeCache.txt was
# created"). Same artifact list as .gitignore's "Engine build artifacts"
# section.
rm -rf build_ext CMakeCache.txt CMakeFiles cmake_install.cmake Makefile \
systemshock src/Libraries/CMakeFiles
echo "== Wiring up prebuilt SDL2/SDL2_mixer/GLEW/fluidsynth-lite (Windows/MinGW) from the image =="
rm -rf build_ext
mkdir -p build_ext
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2 build_ext/built_sdl
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2_mixer build_ext/built_sdl_mixer
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/glew build_ext/built_glew
# engine/CMakeLists.txt's BUNDLED FluidSynth mode hardcodes
# build_ext/fluidsynth-lite/src as the library search path (matching the
# desktop Linux in-source build's own output layout) - same lib/ -> src/
# remapping build-image/prepare-android-project.sh does for the Quest
# build's own prebuilt fluidsynth-lite.
mkdir -p build_ext/fluidsynth-lite/src build_ext/fluidsynth-lite/include
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/lib/. build_ext/fluidsynth-lite/src/
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/include/. build_ext/fluidsynth-lite/include/
echo "== Configuring (CMake, MinGW cross-compile, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
cmake -DCMAKE_TOOLCHAIN_FILE="$MINGW_TOOLCHAIN_FILE" \
-DENABLE_SDL2=BUNDLED -DENABLE_SOUND=BUNDLED -DENABLE_FLUIDSYNTH=BUNDLED .
echo "== Compiling =="
make -j"$(nproc)" systemshock
echo "== Assembling engine/.build-output-win =="
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
cp systemshock.exe "$OUT_DIR/"
# Only SDL2.dll/SDL2_mixer.dll themselves - not SDL2_mixer's own bundled
# codec DLLs (libvorbis, libmodplug, libopus, ...), matching what
# engine/'s own upstream Windows build script (build_win64.sh) ships:
# Shockolate only ever calls Mix_LoadWAV_RW/Mix_HookMusic (same as the
# Quest build - see build-image/Dockerfile's Android layer comment),
# never loading the OGG/MOD/MP3 game data those codecs would be for.
cp build_ext/built_sdl/bin/SDL2.dll build_ext/built_sdl_mixer/bin/SDL2_mixer.dll "$OUT_DIR/"
cp build_ext/built_glew/lib/glew32.dll "$OUT_DIR/"
cp build_ext/fluidsynth-lite/src/*.dll "$OUT_DIR/"
# libgcc/libstdc++ are statically linked (see the Dockerfile's
# MINGW_TOOLCHAIN_FILE), but fluidsynth-lite/SDL2 still pull in the
# MinGW pthread emulation dynamically.
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/"
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done =="
echo "Binary: $OUT_DIR/systemshock.exe"
echo "DLLs: $OUT_DIR/*.dll"
echo "Soundfont: $OUT_DIR/soundfont.sf2"
+23 -4
View File
@@ -22,9 +22,28 @@ cd "$ENGINE_DIR"
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
rm -rf build_ext
mkdir -p build_ext
cp -a /opt/prebuilt/built_sdl build_ext/
cp -a /opt/prebuilt/built_sdl_mixer build_ext/
cp -a /opt/prebuilt/fluidsynth-lite build_ext/
# Some filesystems the repo might be checked out on (e.g. a VirtualBox
# vboxsf shared folder) refuse to create symlinks at all ("Operation not
# permitted"), which plain `cp -a` needs for SDL2/SDL2_mixer's
# libFoo.so -> libFoo.so.N -> libFoo.so.N.M dev-symlink chain. Detect
# that up front, once, and copy real file content instead of recreating
# links if so, rather than failing partway through. `-a` implies
# --preserve=all (which includes hard-link relationships between files,
# not just symlinks - SDL2's install hard-links the two most-specific
# version files together), so --no-preserve=links is needed alongside
# --dereference to avoid that too.
CP_FLAGS=(-a)
if ! ln -s test-target build_ext/.symlink-test 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
else
rm -f build_ext/.symlink-test
fi
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl_mixer build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/fluidsynth-lite build_ext/
echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
rm -f CMakeCache.txt
@@ -38,7 +57,7 @@ rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/lib"
cp systemshock "$OUT_DIR/"
find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \
-name '*.so*' -not -name '*.la' -exec cp -a {} "$OUT_DIR/lib/" \;
-name '*.so*' -not -name '*.la' -exec cp "${CP_FLAGS[@]}" {} "$OUT_DIR/lib/" \;
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done =="
+13
View File
@@ -11,4 +11,17 @@ USER_GID="${HOST_GID:-1000}"
groupadd -g "$USER_GID" builder 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
# /workspace (the bind-mounted repo) may actually be owned by a group the
# host user only has via *supplementary* membership rather than their
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
# up as root:vboxsf on the host and would otherwise be inaccessible to
# `builder` here (falls through to "other", which vboxsf's default mode
# leaves with no permissions at all). Join whatever group actually owns
# /workspace too, if it differs.
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
fi
exec gosu builder "$@"
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
systemshock.exe %*
exit /b %errorlevel%